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 if config.targets.is_empty() {
160 let host = get_host_target().await?;
162 let line_tx = line_bridge(log, LogEvent::Line);
163 run_cargo_build(
164 &config,
165 None,
166 &host,
167 &crate_version,
168 abbaye_version,
169 line_tx,
170 None,
171 )
172 .await
173 } else {
174 let mut join_set = tokio::task::JoinSet::new();
178
179 for target in &config.targets {
180 let config = config.clone();
181 let crate_version = crate_version.clone();
182 let abbaye_version = abbaye_version.to_owned();
183 let target = target.clone();
184 let log = log.clone();
185
186 join_set.spawn(async move {
187 let _ = log.send(LogEvent::ChildStart {
189 id: target.clone(),
190 label: target.clone(),
191 });
192
193 let target_id = target.clone();
196 let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
197 id: target_id.clone(),
198 line: l,
199 });
200
201 let result = if config.parallel && !config.use_cross {
202 let tmpdir = TempDir::new().into_diagnostic()?;
205 let r = run_cargo_build(
206 &config,
207 Some(target.as_str()),
208 &target,
209 &crate_version,
210 &abbaye_version,
211 line_tx,
212 Some(tmpdir.path()),
213 )
214 .await;
215 match r {
218 Ok(artifacts) => relocate_artifacts(artifacts, tmpdir.path()).await,
219 Err(e) => Err(e),
220 }
221 } else {
222 run_cargo_build(
226 &config,
227 Some(target.as_str()),
228 &target,
229 &crate_version,
230 &abbaye_version,
231 line_tx,
232 None,
233 )
234 .await
235 };
236
237 let _ = log.send(LogEvent::ChildFinish {
238 id: target.clone(),
239 success: result.is_ok(),
240 summary: match &result {
241 Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
242 Err(e) => e.to_string(),
243 },
244 });
245
246 result
247 });
248 }
249
250 let mut all_artifacts = Vec::new();
251 while let Some(res) = join_set.join_next().await {
252 all_artifacts.extend(res.into_diagnostic()??);
253 }
254 Ok(all_artifacts)
255 }
256 }
257}
258
259fn line_bridge(
264 log: LogSender,
265 f: impl Fn(String) -> LogEvent + Send + 'static,
266) -> UnboundedSender<String> {
267 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
268 tokio::spawn(async move {
269 while let Some(line) = rx.recv().await {
270 let _ = log.send(f(line));
271 }
272 });
273 tx
274}
275
276#[derive(Deserialize)]
279struct CargoMessage {
280 reason: String,
281 package_id: Option<String>,
285 target: Option<CargoMessageTarget>,
286 filenames: Option<Vec<String>>,
287}
288
289#[derive(Deserialize)]
290struct CargoMessageTarget {
291 name: String,
292 #[serde(default)]
294 kind: Vec<String>,
295}
296
297async fn run_cargo_build(
304 config: &CargoBuilderConfig,
305 target: Option<&str>,
306 triple: &str,
307 version: &str,
308 abbaye_version: &str,
309 line_tx: UnboundedSender<String>,
310 target_dir: Option<&std::path::Path>,
311) -> Result<Vec<ArtifactPath>> {
312 let tool = if config.use_cross { "cross" } else { "cargo" };
313 let mut cmd = Command::new(tool);
314 cmd.args(["build", "--release", "--message-format=json"]);
315 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
316
317 if let Some(t) = target {
318 cmd.args(["--target", t]);
319 }
320
321 if let Some(manifest) = &config.manifest_path {
322 cmd.arg("--manifest-path").arg(manifest);
323 }
324
325 if let Some(dir) = target_dir {
326 cmd.arg("--target-dir").arg(dir);
327 }
328
329 if !config.features.is_empty() {
330 cmd.arg("--features").arg(config.features.join(","));
331 }
332
333 if config.no_default_features {
334 cmd.arg("--no-default-features");
335 }
336
337 let mut child = cmd
338 .stdout(Stdio::piped())
339 .stderr(Stdio::piped())
340 .spawn()
341 .into_diagnostic()?;
342
343 let stderr = child.stderr.take().expect("stderr was piped");
346 tokio::spawn(async move {
347 let mut stderr_lines = BufReader::new(stderr).lines();
348 while let Ok(Some(line)) = stderr_lines.next_line().await {
349 let _ = line_tx.send(line);
350 }
351 });
352
353 let stdout = child.stdout.take().expect("stdout was piped");
354 let mut lines = BufReader::new(stdout).lines();
355
356 let mut artifacts = Vec::new();
357
358 while let Some(line) = lines.next_line().await.into_diagnostic()? {
359 let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
360 continue;
361 };
362
363 if msg.reason != "compiler-artifact" {
364 continue;
365 }
366
367 if !msg
372 .package_id
373 .as_deref()
374 .is_some_and(|id| id.contains("path+file://"))
375 {
376 continue;
377 }
378
379 if msg
381 .target
382 .as_ref()
383 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"))
384 {
385 continue;
386 }
387
388 if !config.bins.is_empty() {
390 let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
391 if !config.bins.iter().any(|b| b == target_name) {
392 continue;
393 }
394 }
395
396 for filename in msg.filenames.unwrap_or_default() {
397 let path = std::path::PathBuf::from(&filename);
398
399 let ext = path
401 .extension()
402 .map(|e| e.to_string_lossy())
403 .unwrap_or_default();
404 if ext == "rlib" || ext == "rmeta" || ext == "d" {
405 continue;
406 }
407
408 if !path.exists() {
409 continue;
410 }
411
412 let stem = path
415 .file_stem()
416 .map(|s| s.to_string_lossy().into_owned())
417 .unwrap_or_default();
418 let dot_ext = path
419 .extension()
420 .map(|e| format!(".{}", e.to_string_lossy()))
421 .unwrap_or_default();
422 let feature_suf =
423 feature_suffix(&config.features, config.no_default_features, &config.suffix);
424 let name = if let Some(ref suf) = feature_suf {
425 format!("{stem}-{version}-{triple}-{suf}{dot_ext}")
426 } else {
427 format!("{stem}-{version}-{triple}{dot_ext}")
428 };
429
430 artifacts.push(ArtifactPath {
431 path,
432 name,
433 hash: None,
434 category: None,
435 group_name: None,
436 group_comment: None,
437 });
438 }
439 }
440
441 let status = child.wait().await.into_diagnostic()?;
442
443 if !status.success() {
444 return Err(miette!(
445 "{tool} build --release failed with exit status: {status}"
446 ));
447 }
448
449 Ok(artifacts)
450}
451
452async fn relocate_artifacts(
461 artifacts: Vec<ArtifactPath>,
462 tmp_root: &std::path::Path,
463) -> Result<Vec<ArtifactPath>> {
464 let mut relocated = Vec::with_capacity(artifacts.len());
465 for artifact in artifacts {
466 let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
467 let stable = std::path::PathBuf::from("target").join(relative);
468 if let Some(parent) = stable.parent() {
469 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
470 }
471 tokio::fs::copy(&artifact.path, &stable)
472 .await
473 .into_diagnostic()?;
474 relocated.push(ArtifactPath {
475 path: stable,
476 name: artifact.name,
477 hash: artifact.hash,
478 category: artifact.category,
479 group_name: None,
480 group_comment: None,
481 });
482 }
483 Ok(relocated)
484}
485
486async fn get_host_target() -> Result<String> {
489 let output = Command::new("rustc")
490 .args(["-vV"])
491 .output()
492 .await
493 .into_diagnostic()?;
494
495 if !output.status.success() {
496 return Err(miette!("rustc -vV failed"));
497 }
498
499 let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
500 stdout
501 .lines()
502 .find(|l| l.starts_with("host:"))
503 .and_then(|l| l.split_whitespace().nth(1))
504 .map(str::to_owned)
505 .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
506}
507
508async fn read_crate_version(manifest_path: Option<&std::path::Path>) -> Result<String> {
511 let path = manifest_path.unwrap_or(std::path::Path::new("Cargo.toml"));
512 let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
513
514 #[derive(Deserialize)]
515 struct Manifest {
516 package: Option<Package>,
517 }
518 #[derive(Deserialize)]
519 struct Package {
520 version: Option<String>,
521 }
522
523 let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
524 manifest
525 .package
526 .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
527 .version
528 .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
529}
530
531fn feature_suffix(
535 features: &[String],
536 no_default_features: bool,
537 suffix: &Option<String>,
538) -> Option<String> {
539 suffix
540 .clone()
541 .or_else(|| {
542 if !features.is_empty() {
543 Some(features.join("+"))
544 } else if no_default_features {
545 Some("no-default".to_owned())
546 } else {
547 None
548 }
549 })
550 .filter(|s| !s.is_empty())
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::path::Path;
557
558 #[test]
561 fn deserialize_compiler_artifact_message() {
562 let json = r#"{
563 "reason": "compiler-artifact",
564 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
565 "target": { "name": "abbaye", "kind": ["bin"] },
566 "filenames": ["/home/user/project/target/release/abbaye"]
567 }"#;
568 let msg: CargoMessage = serde_json::from_str(json).unwrap();
569 assert_eq!(msg.reason, "compiler-artifact");
570 assert!(msg.package_id.unwrap().contains("path+file://"));
571 let target = msg.target.unwrap();
572 assert_eq!(target.name, "abbaye");
573 assert_eq!(target.kind, vec!["bin"]);
574 assert_eq!(
575 msg.filenames.unwrap(),
576 vec!["/home/user/project/target/release/abbaye"]
577 );
578 }
579
580 #[test]
581 fn deserialize_build_script_message_correctly_skipped() {
582 let json = r#"{
583 "reason": "compiler-artifact",
584 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
585 "target": { "name": "build-script-build", "kind": ["custom-build"] },
586 "filenames": ["/home/user/project/target/release/build-script-build"]
587 }"#;
588 let msg: CargoMessage = serde_json::from_str(json).unwrap();
589 let is_custom_build = msg
590 .target
591 .as_ref()
592 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"));
593 assert!(is_custom_build, "custom-build target should be identified");
594 }
595
596 #[test]
597 fn deserialize_external_dependency_skipped() {
598 let json = r#"{
599 "reason": "compiler-artifact",
600 "package_id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0",
601 "target": { "name": "serde", "kind": ["lib"] },
602 "filenames": ["/home/user/project/target/release/libserde.rlib"]
603 }"#;
604 let msg: CargoMessage = serde_json::from_str(json).unwrap();
605 let is_local = msg
606 .package_id
607 .as_deref()
608 .is_some_and(|id| id.contains("path+file://"));
609 assert!(
610 !is_local,
611 "external dependency should NOT be identified as local"
612 );
613 }
614
615 #[test]
616 fn deserialize_multiple_filenames_for_bin() {
617 let json = r#"{
618 "reason": "compiler-artifact",
619 "package_id": "path+file:///home/user/project#my-app@0.1.0",
620 "target": { "name": "my-app", "kind": ["bin"] },
621 "filenames": [
622 "/home/user/project/target/release/my-app",
623 "/home/user/project/target/release/my-app.d"
624 ]
625 }"#;
626 let msg: CargoMessage = serde_json::from_str(json).unwrap();
627 let filenames = msg.filenames.unwrap();
628 assert_eq!(filenames.len(), 2);
629 assert!(filenames[0].ends_with("my-app"));
630 assert!(filenames[1].ends_with("my-app.d"));
631 }
632
633 #[test]
634 fn deserialize_cdylib_artifact() {
635 let json = r#"{
636 "reason": "compiler-artifact",
637 "package_id": "path+file:///home/user/project#libfoo@0.1.0",
638 "target": { "name": "libfoo", "kind": ["cdylib"] },
639 "filenames": ["/home/user/project/target/release/liblibfoo.so"]
640 }"#;
641 let msg: CargoMessage = serde_json::from_str(json).unwrap();
642 let filename = msg.filenames.unwrap().into_iter().next().unwrap();
643 let path = Path::new(&filename);
644 let ext = path
645 .extension()
646 .map(|e| e.to_string_lossy())
647 .unwrap_or_default();
648 assert!(
650 !matches!(ext.as_ref(), "rlib" | "rmeta" | "d"),
651 "cdylib .so file should not be skipped"
652 );
653 }
654
655 #[test]
658 fn artifact_name_includes_version_and_triple() {
659 let path = Path::new("/target/release/abbaye");
660 let stem = path
661 .file_stem()
662 .map(|s| s.to_string_lossy().into_owned())
663 .unwrap_or_default();
664 let dot_ext = path
665 .extension()
666 .map(|e| format!(".{}", e.to_string_lossy()))
667 .unwrap_or_default();
668 let version = "0.10.0";
669 let triple = "x86_64-unknown-linux-musl";
670 let name = format!("{stem}-{version}-{triple}{dot_ext}");
671 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl");
672 }
673
674 #[test]
675 fn artifact_name_with_exe_extension() {
676 let path = Path::new("/target/release/abbaye.exe");
677 let stem = path
678 .file_stem()
679 .map(|s| s.to_string_lossy().into_owned())
680 .unwrap_or_default();
681 let dot_ext = path
682 .extension()
683 .map(|e| format!(".{}", e.to_string_lossy()))
684 .unwrap_or_default();
685 let name = format!("{stem}-0.10.0-x86_64-pc-windows-msvc{dot_ext}");
686 assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc.exe");
687 }
688
689 #[test]
692 fn suffix_none_when_no_features_and_defaults() {
693 let s = feature_suffix(&[], false, &None);
694 assert_eq!(s, None);
695 }
696
697 #[test]
698 fn suffix_single_feature() {
699 let s = feature_suffix(&["full".into()], false, &None);
700 assert_eq!(s.as_deref(), Some("full"));
701 }
702
703 #[test]
704 fn suffix_multiple_features_joined_with_plus() {
705 let s = feature_suffix(&["foo".into(), "bar".into()], false, &None);
706 assert_eq!(s.as_deref(), Some("foo+bar"));
707 }
708
709 #[test]
710 fn suffix_no_default_without_features() {
711 let s = feature_suffix(&[], true, &None);
712 assert_eq!(s.as_deref(), Some("no-default"));
713 }
714
715 #[test]
716 fn suffix_no_default_with_features_uses_features() {
717 let s = feature_suffix(&["full".into()], true, &None);
718 assert_eq!(s.as_deref(), Some("full"));
719 }
720
721 #[test]
722 fn suffix_custom_override() {
723 let s = feature_suffix(&["full".into()], false, &Some("production".into()));
724 assert_eq!(s.as_deref(), Some("production"));
725 }
726
727 #[test]
728 fn suffix_empty_string_treated_as_none() {
729 let s = feature_suffix(&["full".into()], false, &Some(String::new()));
730 assert_eq!(s, None);
731 }
732
733 #[test]
736 fn artifact_name_with_single_feature() {
737 let stem = "abbaye";
738 let dot_ext = "";
739 let version = "0.10.0";
740 let triple = "x86_64-unknown-linux-musl";
741 let suf = "full";
742 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
743 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-full");
744 }
745
746 #[test]
747 fn artifact_name_with_exe_and_feature() {
748 let stem = "abbaye";
749 let dot_ext = ".exe";
750 let version = "0.10.0";
751 let triple = "x86_64-pc-windows-msvc";
752 let suf = "lite";
753 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
754 assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc-lite.exe");
755 }
756
757 #[test]
758 fn artifact_name_with_no_default_only() {
759 let stem = "abbaye";
760 let dot_ext = "";
761 let version = "0.10.0";
762 let triple = "x86_64-unknown-linux-gnu";
763 let suf = "no-default";
764 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
765 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-gnu-no-default");
766 }
767
768 #[test]
769 fn artifact_name_with_custom_suffix() {
770 let stem = "abbaye";
771 let dot_ext = "";
772 let version = "0.10.0";
773 let triple = "x86_64-unknown-linux-musl";
774 let suf = "production";
775 let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}");
776 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-production");
777 }
778
779 #[tokio::test]
782 async fn test_relocate_artifacts_copies_to_target() {
783 let tmp = tempfile::tempdir().unwrap();
784 let tmp_root = tmp.path().join("cross-tmp");
785 let triple_dir = tmp_root.join("x86_64-unknown-linux-musl").join("release");
786 tokio::fs::create_dir_all(&triple_dir).await.unwrap();
787 let binary_path = triple_dir.join("abbaye");
788 tokio::fs::write(&binary_path, b"binary content")
789 .await
790 .unwrap();
791
792 let artifacts = vec![ArtifactPath {
793 path: binary_path,
794 name: "abbaye-0.10.0-x86_64-unknown-linux-musl".to_owned(),
795 hash: None,
796 category: None,
797 group_name: None,
798 group_comment: None,
799 }];
800
801 let relocated = relocate_artifacts(artifacts, &tmp_root).await.unwrap();
802 assert_eq!(relocated.len(), 1);
803 let expected = Path::new("target")
804 .join("x86_64-unknown-linux-musl")
805 .join("release")
806 .join("abbaye");
807 assert_eq!(relocated[0].path, expected);
808 assert!(expected.exists(), "binary should exist at canonical path");
809 let content = tokio::fs::read_to_string(&expected).await.unwrap();
810 assert_eq!(content, "binary content");
811 }
812
813 #[tokio::test]
816 async fn test_get_host_target_returns_triple() {
817 let triple = get_host_target().await.unwrap();
818 assert!(!triple.is_empty(), "host target triple should not be empty");
819 assert!(
821 triple.contains('-'),
822 "triple should be dash-separated: {triple}"
823 );
824 }
825
826 #[tokio::test]
829 async fn test_read_crate_version_from_toml() {
830 let tmp = tempfile::tempdir().unwrap();
831 let toml_path = tmp.path().join("Cargo.toml");
832 tokio::fs::write(
833 &toml_path,
834 "[package]\nname = \"test\"\nversion = \"0.5.0\"\n",
835 )
836 .await
837 .unwrap();
838
839 let version = read_crate_version(Some(&toml_path)).await.unwrap();
840 assert_eq!(version, "0.5.0");
841 }
842
843 #[tokio::test]
844 async fn test_read_crate_version_returns_error_on_missing() {
845 let tmp = tempfile::tempdir().unwrap();
846 let toml_path = tmp.path().join("Cargo.toml");
847 tokio::fs::write(&toml_path, "[package]\nname = \"no-version\"\n")
848 .await
849 .unwrap();
850
851 let result = read_crate_version(Some(&toml_path)).await;
852 assert!(
853 result.is_err(),
854 "should error when version field is missing"
855 );
856 }
857
858 #[test]
861 fn use_cross_disables_parallel_isolation() {
862 let uses_isolation = |parallel: bool, use_cross: bool| -> bool { parallel && !use_cross };
868
869 assert!(
870 !uses_isolation(true, true),
871 "parallel=true + use_cross=true should NOT use isolation"
872 );
873 assert!(
874 !uses_isolation(false, true),
875 "parallel=false + use_cross=true should NOT use isolation"
876 );
877 assert!(
878 uses_isolation(true, false),
879 "parallel=true + use_cross=false SHOULD use isolation"
880 );
881 assert!(
882 !uses_isolation(false, false),
883 "parallel=false + use_cross=false should NOT use isolation"
884 );
885 }
886
887 #[test]
890 fn skips_rlib_and_rmeta_and_dot_d_files() {
891 for ext in ["rlib", "rmeta", "d"] {
892 let filename = format!("/target/release/libfoo.{ext}");
893 let path = Path::new(&filename);
894 let ext_str = path
895 .extension()
896 .map(|e| e.to_string_lossy())
897 .unwrap_or_default();
898 assert!(
899 ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d",
900 "{ext} should match skip condition"
901 );
902 }
903 }
904
905 #[test]
906 fn keeps_executable_and_cdylib_files() {
907 for ext in ["", "exe", "so", "dylib", "dll"] {
908 let filename = if ext.is_empty() {
909 "/target/release/my-bin".to_owned()
910 } else {
911 format!("/target/release/my-bin.{ext}")
912 };
913 let path = Path::new(&filename);
914 let ext_str = path
915 .extension()
916 .map(|e| e.to_string_lossy())
917 .unwrap_or_default();
918 let is_skippable = ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d";
919 assert!(!is_skippable, "{ext} should NOT be skipped");
920 }
921 }
922
923 #[test]
926 fn default_parallel_is_true() {
927 assert!(default_parallel(), "parallel should default to true");
928 }
929
930 #[test]
931 fn use_cross_defaults_to_false() {
932 let config = CargoBuilderConfig::default();
933 assert!(!config.use_cross, "use_cross should default to false");
934 }
935
936 #[test]
939 fn features_defaults_to_empty() {
940 let config = CargoBuilderConfig::default();
941 assert!(config.features.is_empty());
942 }
943
944 #[test]
945 fn no_default_features_defaults_to_false() {
946 let config = CargoBuilderConfig::default();
947 assert!(!config.no_default_features);
948 }
949
950 #[test]
951 fn suffix_defaults_to_none() {
952 let config = CargoBuilderConfig::default();
953 assert!(config.suffix.is_none());
954 }
955}
956
957#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
959pub struct CargoDocBuilderConfig {
960 pub manifest_path: Option<std::path::PathBuf>,
965
966 #[serde(default)]
968 pub no_deps: bool,
969}
970
971pub struct CargoDocBuilder;
973
974impl Builder for CargoDocBuilder {
975 type ConfigType = CargoDocBuilderConfig;
976
977 async fn build(
978 &self,
979 config: Self::ConfigType,
980 abbaye_version: &str,
981 log: LogSender,
982 ) -> Result<Vec<ArtifactPath>> {
983 let mut cmd = Command::new("cargo");
984 cmd.arg("doc");
985 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
986
987 if config.no_deps {
988 cmd.arg("--no-deps");
989 }
990
991 if let Some(manifest) = &config.manifest_path {
992 cmd.arg("--manifest-path").arg(manifest);
993 }
994
995 let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
996
997 let stderr = child.stderr.take().expect("stderr was piped");
998 tokio::spawn(async move {
999 let mut stderr_lines = BufReader::new(stderr).lines();
1000 while let Ok(Some(line)) = stderr_lines.next_line().await {
1001 let _ = log.send(LogEvent::Line(line));
1002 }
1003 });
1004
1005 let status = child.wait().await.into_diagnostic()?;
1006
1007 if !status.success() {
1008 return Err(miette!("cargo doc failed with exit status: {status}"));
1009 }
1010
1011 let doc_dir = config
1012 .manifest_path
1013 .as_deref()
1014 .and_then(|p| p.parent())
1015 .unwrap_or_else(|| std::path::Path::new("."))
1016 .join("target/doc");
1017
1018 if !doc_dir.exists() {
1019 return Err(miette!("doc directory not found at {}", doc_dir.display()));
1020 }
1021
1022 Ok(vec![ArtifactPath {
1023 path: doc_dir,
1024 name: "doc".to_owned(),
1025 hash: None,
1026 category: None,
1027 group_name: None,
1028 group_comment: None,
1029 }])
1030 }
1031}