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)]
74 pub features: Vec<String>,
75
76 #[serde(default)]
81 pub no_default_features: bool,
82
83 pub suffix: Option<String>,
97
98 #[serde(default = "default_parallel")]
126 pub parallel: bool,
127}
128
129impl Default for CargoBuilderConfig {
130 fn default() -> Self {
131 Self {
132 targets: Vec::new(),
133 manifest_path: None,
134 bins: Vec::new(),
135 parallel: default_parallel(),
136 use_cross: false,
137 features: Vec::new(),
138 no_default_features: false,
139 suffix: None,
140 }
141 }
142}
143
144pub struct CargoBuilder;
147
148impl Builder for CargoBuilder {
149 type ConfigType = CargoBuilderConfig;
150
151 async fn build(
152 &self,
153 config: Self::ConfigType,
154 abbaye_version: &str,
155 log: LogSender,
156 ) -> Result<Vec<ArtifactPath>> {
157 let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;
158
159 let feat_suf = feature_suffix(&config.features, config.no_default_features, &config.suffix);
160 let rel_override = feat_suf.as_deref().map(|s| format!("release-{s}"));
161
162 if config.targets.is_empty() {
163 let host = get_host_target().await?;
165 let line_tx = line_bridge(log, LogEvent::Line);
166 if let Some(ref rel) = rel_override {
167 let tmpdir = TempDir::new().into_diagnostic()?;
170 let artifacts = run_cargo_build(
171 &config,
172 None,
173 &host,
174 &crate_version,
175 abbaye_version,
176 line_tx,
177 Some(tmpdir.path()),
178 )
179 .await?;
180 relocate_artifacts(artifacts, tmpdir.path(), Some(rel)).await
181 } else {
182 run_cargo_build(
183 &config,
184 None,
185 &host,
186 &crate_version,
187 abbaye_version,
188 line_tx,
189 None,
190 )
191 .await
192 }
193 } else {
194 let mut join_set = tokio::task::JoinSet::new();
198
199 for target in &config.targets {
200 let config = config.clone();
201 let crate_version = crate_version.clone();
202 let abbaye_version = abbaye_version.to_owned();
203 let target = target.clone();
204 let log = log.clone();
205 let rel = rel_override.clone();
206
207 join_set.spawn(async move {
208 let _ = log.send(LogEvent::ChildStart {
210 id: target.clone(),
211 label: target.clone(),
212 });
213
214 let target_id = target.clone();
217 let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
218 id: target_id.clone(),
219 line: l,
220 });
221
222 let use_isolation = (config.parallel || rel.is_some()) && !config.use_cross;
223 let result = if use_isolation {
224 let tmpdir = TempDir::new().into_diagnostic()?;
229 let r = run_cargo_build(
230 &config,
231 Some(target.as_str()),
232 &target,
233 &crate_version,
234 &abbaye_version,
235 line_tx,
236 Some(tmpdir.path()),
237 )
238 .await;
239 match r {
242 Ok(artifacts) => {
243 relocate_artifacts(artifacts, tmpdir.path(), rel.as_deref()).await
244 }
245 Err(e) => Err(e),
246 }
247 } else {
248 run_cargo_build(
252 &config,
253 Some(target.as_str()),
254 &target,
255 &crate_version,
256 &abbaye_version,
257 line_tx,
258 None,
259 )
260 .await
261 };
262
263 let _ = log.send(LogEvent::ChildFinish {
264 id: target.clone(),
265 success: result.is_ok(),
266 summary: match &result {
267 Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
268 Err(e) => e.to_string(),
269 },
270 });
271
272 result
273 });
274 }
275
276 let mut all_artifacts = Vec::new();
277 while let Some(res) = join_set.join_next().await {
278 all_artifacts.extend(res.into_diagnostic()??);
279 }
280 Ok(all_artifacts)
281 }
282 }
283}
284
285fn line_bridge(
290 log: LogSender,
291 f: impl Fn(String) -> LogEvent + Send + 'static,
292) -> UnboundedSender<String> {
293 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
294 tokio::spawn(async move {
295 while let Some(line) = rx.recv().await {
296 let _ = log.send(f(line));
297 }
298 });
299 tx
300}
301
302#[derive(Deserialize)]
305struct CargoMessage {
306 reason: String,
307 package_id: Option<String>,
311 target: Option<CargoMessageTarget>,
312 filenames: Option<Vec<String>>,
313}
314
315#[derive(Deserialize)]
316struct CargoMessageTarget {
317 name: String,
318 #[serde(default)]
320 kind: Vec<String>,
321}
322
323async fn run_cargo_build(
330 config: &CargoBuilderConfig,
331 target: Option<&str>,
332 triple: &str,
333 version: &str,
334 abbaye_version: &str,
335 line_tx: UnboundedSender<String>,
336 target_dir: Option<&std::path::Path>,
337) -> Result<Vec<ArtifactPath>> {
338 let tool = if config.use_cross { "cross" } else { "cargo" };
339 let mut cmd = Command::new(tool);
340 cmd.args(["build", "--release", "--message-format=json"]);
341 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
342
343 if let Some(t) = target {
344 cmd.args(["--target", t]);
345 }
346
347 if let Some(manifest) = &config.manifest_path {
348 cmd.arg("--manifest-path").arg(manifest);
349 }
350
351 if let Some(dir) = target_dir {
352 cmd.arg("--target-dir").arg(dir);
353 }
354
355 if !config.features.is_empty() {
356 cmd.arg("--features").arg(config.features.join(","));
357 }
358
359 if config.no_default_features {
360 cmd.arg("--no-default-features");
361 }
362
363 let mut child = cmd
364 .stdout(Stdio::piped())
365 .stderr(Stdio::piped())
366 .spawn()
367 .into_diagnostic()?;
368
369 let stderr = child.stderr.take().expect("stderr was piped");
372 tokio::spawn(async move {
373 let mut stderr_lines = BufReader::new(stderr).lines();
374 while let Ok(Some(line)) = stderr_lines.next_line().await {
375 let _ = line_tx.send(line);
376 }
377 });
378
379 let stdout = child.stdout.take().expect("stdout was piped");
380 let mut lines = BufReader::new(stdout).lines();
381
382 let mut artifacts = Vec::new();
383
384 while let Some(line) = lines.next_line().await.into_diagnostic()? {
385 let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
386 continue;
387 };
388
389 if msg.reason != "compiler-artifact" {
390 continue;
391 }
392
393 if !msg
398 .package_id
399 .as_deref()
400 .is_some_and(|id| id.contains("path+file://"))
401 {
402 continue;
403 }
404
405 if msg
407 .target
408 .as_ref()
409 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"))
410 {
411 continue;
412 }
413
414 if !config.bins.is_empty() {
416 let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
417 if !config.bins.iter().any(|b| b == target_name) {
418 continue;
419 }
420 }
421
422 for filename in msg.filenames.unwrap_or_default() {
423 let path = std::path::PathBuf::from(&filename);
424
425 let ext = path
427 .extension()
428 .map(|e| e.to_string_lossy())
429 .unwrap_or_default();
430 if ext == "rlib" || ext == "rmeta" || ext == "d" {
431 continue;
432 }
433
434 if !path.exists() {
435 continue;
436 }
437
438 let stem = path
441 .file_stem()
442 .map(|s| s.to_string_lossy().into_owned())
443 .unwrap_or_default();
444 let dot_ext = path
445 .extension()
446 .map(|e| format!(".{}", e.to_string_lossy()))
447 .unwrap_or_default();
448 let feature_suf =
449 feature_suffix(&config.features, config.no_default_features, &config.suffix);
450 let name = if let Some(ref suf) = feature_suf {
451 format!("{stem}-{version}-{triple}-{suf}{dot_ext}")
452 } else {
453 format!("{stem}-{version}-{triple}{dot_ext}")
454 };
455
456 artifacts.push(ArtifactPath {
457 path,
458 name,
459 hash: None,
460 category: None,
461 group_name: None,
462 group_comment: None,
463 });
464 }
465 }
466
467 let status = child.wait().await.into_diagnostic()?;
468
469 if !status.success() {
470 return Err(miette!(
471 "{tool} build --release failed with exit status: {status}"
472 ));
473 }
474
475 Ok(artifacts)
476}
477
478async fn relocate_artifacts(
491 artifacts: Vec<ArtifactPath>,
492 tmp_root: &std::path::Path,
493 release_override: Option<&str>,
494) -> Result<Vec<ArtifactPath>> {
495 let mut relocated = Vec::with_capacity(artifacts.len());
496 for artifact in artifacts {
497 let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
498 let stable = if let Some(rel) = release_override {
499 let relative_str = relative.to_string_lossy();
500 let replaced = relative_str.replace("/release/", &format!("/{rel}/"));
501 std::path::PathBuf::from("target").join(&replaced)
502 } else {
503 std::path::PathBuf::from("target").join(relative)
504 };
505 if let Some(parent) = stable.parent() {
506 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
507 }
508 tokio::fs::copy(&artifact.path, &stable)
509 .await
510 .into_diagnostic()?;
511 relocated.push(ArtifactPath {
512 path: stable,
513 name: artifact.name,
514 hash: artifact.hash,
515 category: artifact.category,
516 group_name: None,
517 group_comment: None,
518 });
519 }
520 Ok(relocated)
521}
522
523async fn get_host_target() -> Result<String> {
526 let output = Command::new("rustc")
527 .args(["-vV"])
528 .output()
529 .await
530 .into_diagnostic()?;
531
532 if !output.status.success() {
533 return Err(miette!("rustc -vV failed"));
534 }
535
536 let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
537 stdout
538 .lines()
539 .find(|l| l.starts_with("host:"))
540 .and_then(|l| l.split_whitespace().nth(1))
541 .map(str::to_owned)
542 .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
543}
544
545async fn read_crate_version(manifest_path: Option<&std::path::Path>) -> Result<String> {
548 let path = manifest_path.unwrap_or(std::path::Path::new("Cargo.toml"));
549 let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
550
551 #[derive(Deserialize)]
552 struct Manifest {
553 package: Option<Package>,
554 }
555 #[derive(Deserialize)]
556 struct Package {
557 version: Option<String>,
558 }
559
560 let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
561 manifest
562 .package
563 .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
564 .version
565 .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
566}
567
568fn feature_suffix(
572 features: &[String],
573 no_default_features: bool,
574 suffix: &Option<String>,
575) -> Option<String> {
576 suffix
577 .clone()
578 .or_else(|| {
579 if !features.is_empty() {
580 Some(features.join("+"))
581 } else if no_default_features {
582 Some("no-default".to_owned())
583 } else {
584 None
585 }
586 })
587 .filter(|s| !s.is_empty())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use std::path::Path;
594
595 #[test]
598 fn deserialize_compiler_artifact_message() {
599 let json = r#"{
600 "reason": "compiler-artifact",
601 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
602 "target": { "name": "abbaye", "kind": ["bin"] },
603 "filenames": ["/home/user/project/target/release/abbaye"]
604 }"#;
605 let msg: CargoMessage = serde_json::from_str(json).unwrap();
606 assert_eq!(msg.reason, "compiler-artifact");
607 assert!(msg.package_id.unwrap().contains("path+file://"));
608 let target = msg.target.unwrap();
609 assert_eq!(target.name, "abbaye");
610 assert_eq!(target.kind, vec!["bin"]);
611 assert_eq!(
612 msg.filenames.unwrap(),
613 vec!["/home/user/project/target/release/abbaye"]
614 );
615 }
616
617 #[test]
618 fn deserialize_build_script_message_correctly_skipped() {
619 let json = r#"{
620 "reason": "compiler-artifact",
621 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
622 "target": { "name": "build-script-build", "kind": ["custom-build"] },
623 "filenames": ["/home/user/project/target/release/build-script-build"]
624 }"#;
625 let msg: CargoMessage = serde_json::from_str(json).unwrap();
626 let is_custom_build = msg
627 .target
628 .as_ref()
629 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"));
630 assert!(is_custom_build, "custom-build target should be identified");
631 }
632
633 #[test]
634 fn deserialize_external_dependency_skipped() {
635 let json = r#"{
636 "reason": "compiler-artifact",
637 "package_id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0",
638 "target": { "name": "serde", "kind": ["lib"] },
639 "filenames": ["/home/user/project/target/release/libserde.rlib"]
640 }"#;
641 let msg: CargoMessage = serde_json::from_str(json).unwrap();
642 let is_local = msg
643 .package_id
644 .as_deref()
645 .is_some_and(|id| id.contains("path+file://"));
646 assert!(
647 !is_local,
648 "external dependency should NOT be identified as local"
649 );
650 }
651
652 #[test]
653 fn deserialize_multiple_filenames_for_bin() {
654 let json = r#"{
655 "reason": "compiler-artifact",
656 "package_id": "path+file:///home/user/project#my-app@0.1.0",
657 "target": { "name": "my-app", "kind": ["bin"] },
658 "filenames": [
659 "/home/user/project/target/release/my-app",
660 "/home/user/project/target/release/my-app.d"
661 ]
662 }"#;
663 let msg: CargoMessage = serde_json::from_str(json).unwrap();
664 let filenames = msg.filenames.unwrap();
665 assert_eq!(filenames.len(), 2);
666 assert!(filenames[0].ends_with("my-app"));
667 assert!(filenames[1].ends_with("my-app.d"));
668 }
669
670 #[test]
671 fn deserialize_cdylib_artifact() {
672 let json = r#"{
673 "reason": "compiler-artifact",
674 "package_id": "path+file:///home/user/project#libfoo@0.1.0",
675 "target": { "name": "libfoo", "kind": ["cdylib"] },
676 "filenames": ["/home/user/project/target/release/liblibfoo.so"]
677 }"#;
678 let msg: CargoMessage = serde_json::from_str(json).unwrap();
679 let filename = msg.filenames.unwrap().into_iter().next().unwrap();
680 let path = Path::new(&filename);
681 let ext = path
682 .extension()
683 .map(|e| e.to_string_lossy())
684 .unwrap_or_default();
685 assert!(
687 !matches!(ext.as_ref(), "rlib" | "rmeta" | "d"),
688 "cdylib .so file should not be skipped"
689 );
690 }
691
692 #[test]
695 fn artifact_name_includes_version_and_triple() {
696 let path = Path::new("/target/release/abbaye");
697 let stem = path
698 .file_stem()
699 .map(|s| s.to_string_lossy().into_owned())
700 .unwrap_or_default();
701 let dot_ext = path
702 .extension()
703 .map(|e| format!(".{}", e.to_string_lossy()))
704 .unwrap_or_default();
705 let version = "0.10.0";
706 let triple = "x86_64-unknown-linux-musl";
707 let name = format!("{stem}-{version}-{triple}{dot_ext}");
708 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl");
709 }
710
711 #[test]
712 fn artifact_name_with_exe_extension() {
713 let path = Path::new("/target/release/abbaye.exe");
714 let stem = path
715 .file_stem()
716 .map(|s| s.to_string_lossy().into_owned())
717 .unwrap_or_default();
718 let dot_ext = path
719 .extension()
720 .map(|e| format!(".{}", e.to_string_lossy()))
721 .unwrap_or_default();
722 let name = format!("{stem}-0.10.0-x86_64-pc-windows-msvc{dot_ext}");
723 assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc.exe");
724 }
725
726 #[test]
729 fn suffix_none_when_no_features_and_defaults() {
730 let s = feature_suffix(&[], false, &None);
731 assert_eq!(s, None);
732 }
733
734 #[test]
735 fn suffix_single_feature() {
736 let s = feature_suffix(&["full".into()], false, &None);
737 assert_eq!(s.as_deref(), Some("full"));
738 }
739
740 #[test]
741 fn suffix_multiple_features_joined_with_plus() {
742 let s = feature_suffix(&["foo".into(), "bar".into()], false, &None);
743 assert_eq!(s.as_deref(), Some("foo+bar"));
744 }
745
746 #[test]
747 fn suffix_no_default_without_features() {
748 let s = feature_suffix(&[], true, &None);
749 assert_eq!(s.as_deref(), Some("no-default"));
750 }
751
752 #[test]
753 fn suffix_no_default_with_features_uses_features() {
754 let s = feature_suffix(&["full".into()], true, &None);
755 assert_eq!(s.as_deref(), Some("full"));
756 }
757
758 #[test]
759 fn suffix_custom_override() {
760 let s = feature_suffix(&["full".into()], false, &Some("production".into()));
761 assert_eq!(s.as_deref(), Some("production"));
762 }
763
764 #[test]
765 fn suffix_empty_string_treated_as_none() {
766 let s = feature_suffix(&["full".into()], false, &Some(String::new()));
767 assert_eq!(s, None);
768 }
769
770 #[test]
773 fn artifact_name_with_single_feature() {
774 let stem = "abbaye";
775 let dot_ext = "";
776 let version = "0.10.0";
777 let triple = "x86_64-unknown-linux-musl";
778 let suf = "full";
779 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
780 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-full");
781 }
782
783 #[test]
784 fn artifact_name_with_exe_and_feature() {
785 let stem = "abbaye";
786 let dot_ext = ".exe";
787 let version = "0.10.0";
788 let triple = "x86_64-pc-windows-msvc";
789 let suf = "lite";
790 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
791 assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc-lite.exe");
792 }
793
794 #[test]
795 fn artifact_name_with_no_default_only() {
796 let stem = "abbaye";
797 let dot_ext = "";
798 let version = "0.10.0";
799 let triple = "x86_64-unknown-linux-gnu";
800 let suf = "no-default";
801 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
802 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-gnu-no-default");
803 }
804
805 #[test]
806 fn artifact_name_with_custom_suffix() {
807 let stem = "abbaye";
808 let dot_ext = "";
809 let version = "0.10.0";
810 let triple = "x86_64-unknown-linux-musl";
811 let suf = "production";
812 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
813 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-production");
814 }
815
816 #[tokio::test]
819 async fn test_relocate_artifacts_copies_to_target() {
820 let tmp = tempfile::tempdir().unwrap();
821 let tmp_root = tmp.path().join("cross-tmp");
822 let triple_dir = tmp_root.join("x86_64-unknown-linux-musl").join("release");
823 tokio::fs::create_dir_all(&triple_dir).await.unwrap();
824 let binary_path = triple_dir.join("abbaye");
825 tokio::fs::write(&binary_path, b"binary content")
826 .await
827 .unwrap();
828
829 let artifacts = vec![ArtifactPath {
830 path: binary_path,
831 name: "abbaye-0.10.0-x86_64-unknown-linux-musl".to_owned(),
832 hash: None,
833 category: None,
834 group_name: None,
835 group_comment: None,
836 }];
837
838 let relocated = relocate_artifacts(artifacts, &tmp_root, None)
839 .await
840 .unwrap();
841 assert_eq!(relocated.len(), 1);
842 let expected = Path::new("target")
843 .join("x86_64-unknown-linux-musl")
844 .join("release")
845 .join("abbaye");
846 assert_eq!(relocated[0].path, expected);
847 assert!(expected.exists(), "binary should exist at canonical path");
848 let content = tokio::fs::read_to_string(&expected).await.unwrap();
849 assert_eq!(content, "binary content");
850 }
851
852 #[tokio::test]
855 async fn relocate_artifacts_with_release_override() {
856 let tmp = tempfile::tempdir().unwrap();
857 let tmp_root = tmp.path().join("cross-tmp");
858 let triple = "arm-unknown-linux-gnueabihf";
859 let triple_dir = tmp_root.join(triple).join("release");
860 tokio::fs::create_dir_all(&triple_dir).await.unwrap();
861 let binary_path = triple_dir.join("myapp");
862 tokio::fs::write(&binary_path, b"feature-specific content")
863 .await
864 .unwrap();
865
866 let artifacts = vec![ArtifactPath {
867 path: binary_path,
868 name: "myapp-0.10.0-arm-unknown-linux-gnueabihf-no-default".to_owned(),
869 hash: None,
870 category: None,
871 group_name: None,
872 group_comment: None,
873 }];
874
875 let relocated = relocate_artifacts(artifacts, &tmp_root, Some("release-no-default"))
876 .await
877 .unwrap();
878 assert_eq!(relocated.len(), 1);
879 let expected = Path::new("target")
880 .join(triple)
881 .join("release-no-default")
882 .join("myapp");
883 assert_eq!(relocated[0].path, expected);
884 assert!(expected.exists(), "binary should exist at override path");
885 let content = tokio::fs::read_to_string(&expected).await.unwrap();
886 assert_eq!(content, "feature-specific content");
887 }
888
889 #[tokio::test]
890 async fn test_get_host_target_returns_triple() {
891 let triple = get_host_target().await.unwrap();
892 assert!(!triple.is_empty(), "host target triple should not be empty");
893 assert!(
895 triple.contains('-'),
896 "triple should be dash-separated: {triple}"
897 );
898 }
899
900 #[tokio::test]
903 async fn test_read_crate_version_from_toml() {
904 let tmp = tempfile::tempdir().unwrap();
905 let toml_path = tmp.path().join("Cargo.toml");
906 tokio::fs::write(
907 &toml_path,
908 "[package]\nname = \"test\"\nversion = \"0.5.0\"\n",
909 )
910 .await
911 .unwrap();
912
913 let version = read_crate_version(Some(&toml_path)).await.unwrap();
914 assert_eq!(version, "0.5.0");
915 }
916
917 #[tokio::test]
918 async fn test_read_crate_version_returns_error_on_missing() {
919 let tmp = tempfile::tempdir().unwrap();
920 let toml_path = tmp.path().join("Cargo.toml");
921 tokio::fs::write(&toml_path, "[package]\nname = \"no-version\"\n")
922 .await
923 .unwrap();
924
925 let result = read_crate_version(Some(&toml_path)).await;
926 assert!(
927 result.is_err(),
928 "should error when version field is missing"
929 );
930 }
931
932 #[test]
935 fn use_cross_disables_parallel_isolation() {
936 let uses_isolation = |parallel: bool, use_cross: bool| -> bool { parallel && !use_cross };
942
943 assert!(
944 !uses_isolation(true, true),
945 "parallel=true + use_cross=true should NOT use isolation"
946 );
947 assert!(
948 !uses_isolation(false, true),
949 "parallel=false + use_cross=true should NOT use isolation"
950 );
951 assert!(
952 uses_isolation(true, false),
953 "parallel=true + use_cross=false SHOULD use isolation"
954 );
955 assert!(
956 !uses_isolation(false, false),
957 "parallel=false + use_cross=false should NOT use isolation"
958 );
959 }
960
961 #[test]
964 fn skips_rlib_and_rmeta_and_dot_d_files() {
965 for ext in ["rlib", "rmeta", "d"] {
966 let filename = format!("/target/release/libfoo.{ext}");
967 let path = Path::new(&filename);
968 let ext_str = path
969 .extension()
970 .map(|e| e.to_string_lossy())
971 .unwrap_or_default();
972 assert!(
973 ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d",
974 "{ext} should match skip condition"
975 );
976 }
977 }
978
979 #[test]
980 fn keeps_executable_and_cdylib_files() {
981 for ext in ["", "exe", "so", "dylib", "dll"] {
982 let filename = if ext.is_empty() {
983 "/target/release/my-bin".to_owned()
984 } else {
985 format!("/target/release/my-bin.{ext}")
986 };
987 let path = Path::new(&filename);
988 let ext_str = path
989 .extension()
990 .map(|e| e.to_string_lossy())
991 .unwrap_or_default();
992 let is_skippable = ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d";
993 assert!(!is_skippable, "{ext} should NOT be skipped");
994 }
995 }
996
997 #[test]
1000 fn default_parallel_is_true() {
1001 assert!(default_parallel(), "parallel should default to true");
1002 }
1003
1004 #[test]
1005 fn use_cross_defaults_to_false() {
1006 let config = CargoBuilderConfig::default();
1007 assert!(!config.use_cross, "use_cross should default to false");
1008 }
1009
1010 #[test]
1013 fn features_defaults_to_empty() {
1014 let config = CargoBuilderConfig::default();
1015 assert!(config.features.is_empty());
1016 }
1017
1018 #[test]
1019 fn no_default_features_defaults_to_false() {
1020 let config = CargoBuilderConfig::default();
1021 assert!(!config.no_default_features);
1022 }
1023
1024 #[test]
1025 fn suffix_defaults_to_none() {
1026 let config = CargoBuilderConfig::default();
1027 assert!(config.suffix.is_none());
1028 }
1029}
1030
1031#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
1033pub struct CargoDocBuilderConfig {
1034 pub manifest_path: Option<std::path::PathBuf>,
1039
1040 #[serde(default)]
1042 pub no_deps: bool,
1043}
1044
1045pub struct CargoDocBuilder;
1047
1048impl Builder for CargoDocBuilder {
1049 type ConfigType = CargoDocBuilderConfig;
1050
1051 async fn build(
1052 &self,
1053 config: Self::ConfigType,
1054 abbaye_version: &str,
1055 log: LogSender,
1056 ) -> Result<Vec<ArtifactPath>> {
1057 let mut cmd = Command::new("cargo");
1058 cmd.arg("doc");
1059 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
1060
1061 if config.no_deps {
1062 cmd.arg("--no-deps");
1063 }
1064
1065 if let Some(manifest) = &config.manifest_path {
1066 cmd.arg("--manifest-path").arg(manifest);
1067 }
1068
1069 let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
1070
1071 let stderr = child.stderr.take().expect("stderr was piped");
1072 tokio::spawn(async move {
1073 let mut stderr_lines = BufReader::new(stderr).lines();
1074 while let Ok(Some(line)) = stderr_lines.next_line().await {
1075 let _ = log.send(LogEvent::Line(line));
1076 }
1077 });
1078
1079 let status = child.wait().await.into_diagnostic()?;
1080
1081 if !status.success() {
1082 return Err(miette!("cargo doc failed with exit status: {status}"));
1083 }
1084
1085 let doc_dir = config
1086 .manifest_path
1087 .as_deref()
1088 .and_then(|p| p.parent())
1089 .unwrap_or_else(|| std::path::Path::new("."))
1090 .join("target/doc");
1091
1092 if !doc_dir.exists() {
1093 return Err(miette!("doc directory not found at {}", doc_dir.display()));
1094 }
1095
1096 Ok(vec![ArtifactPath {
1097 path: doc_dir,
1098 name: "doc".to_owned(),
1099 hash: None,
1100 category: None,
1101 group_name: None,
1102 group_comment: None,
1103 }])
1104 }
1105}