1use crate::artifact::{Artifact, ArtifactBuilder, ChecksumsManifest, PackagedArtifact, Target};
7use crate::backends::{BackendContext, PublishResult, ReleaseBackend};
8use crate::error::{Error, Result};
9use std::path::PathBuf;
10use tracing::{debug, info, warn};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ReleasePhase {
15 Build,
17 Package,
19 Publish,
21 Full,
23}
24
25#[derive(Debug, Clone)]
27pub struct OrchestratorConfig {
28 pub name: String,
30 pub version: String,
32 pub targets: Vec<Target>,
34 pub output_dir: PathBuf,
36 pub dry_run: bool,
38 pub download_base_url: Option<String>,
40}
41
42impl OrchestratorConfig {
43 #[must_use]
45 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
46 Self {
47 name: name.into(),
48 version: version.into(),
49 targets: vec![Target::LinuxX64, Target::LinuxArm64, Target::DarwinArm64],
50 output_dir: PathBuf::from("target/release-artifacts"),
51 dry_run: false,
52 download_base_url: None,
53 }
54 }
55
56 #[must_use]
58 pub fn with_targets(mut self, targets: Vec<Target>) -> Self {
59 self.targets = targets;
60 self
61 }
62
63 #[must_use]
65 pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
66 self.output_dir = dir.into();
67 self
68 }
69
70 #[must_use]
72 pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
73 self.dry_run = dry_run;
74 self
75 }
76
77 #[must_use]
79 pub fn with_download_url(mut self, url: impl Into<String>) -> Self {
80 self.download_base_url = Some(url.into());
81 self
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct ReleaseReport {
88 pub phase: ReleasePhase,
90 pub artifacts: Vec<PackagedArtifact>,
92 pub backend_results: Vec<PublishResult>,
94 pub success: bool,
96}
97
98impl ReleaseReport {
99 #[must_use]
101 const fn empty(phase: ReleasePhase) -> Self {
102 Self {
103 phase,
104 artifacts: Vec::new(),
105 backend_results: Vec::new(),
106 success: true,
107 }
108 }
109
110 #[must_use]
112 const fn with_artifacts(phase: ReleasePhase, artifacts: Vec<PackagedArtifact>) -> Self {
113 Self {
114 phase,
115 artifacts,
116 backend_results: Vec::new(),
117 success: true,
118 }
119 }
120
121 fn add_backend_results(&mut self, results: Vec<PublishResult>) {
123 self.success = results.iter().all(|r| r.success);
124 self.backend_results = results;
125 }
126}
127
128pub struct ReleaseOrchestrator {
132 config: OrchestratorConfig,
133 backends: Vec<Box<dyn ReleaseBackend>>,
134}
135
136impl ReleaseOrchestrator {
137 #[must_use]
139 pub fn new(config: OrchestratorConfig) -> Self {
140 Self {
141 config,
142 backends: Vec::new(),
143 }
144 }
145
146 #[must_use]
148 pub fn with_backend(mut self, backend: Box<dyn ReleaseBackend>) -> Self {
149 self.backends.push(backend);
150 self
151 }
152
153 #[must_use]
155 pub fn with_backends(mut self, backends: Vec<Box<dyn ReleaseBackend>>) -> Self {
156 self.backends.extend(backends);
157 self
158 }
159
160 #[must_use]
162 pub const fn config(&self) -> &OrchestratorConfig {
163 &self.config
164 }
165
166 pub async fn run(&self, phase: ReleasePhase) -> Result<ReleaseReport> {
176 match phase {
177 ReleasePhase::Build => self.build().await,
178 ReleasePhase::Package => self.package().await,
179 ReleasePhase::Publish => self.publish_only().await,
180 ReleasePhase::Full => self.full_pipeline().await,
181 }
182 }
183
184 #[allow(clippy::unused_async)]
190 async fn build(&self) -> Result<ReleaseReport> {
191 info!(
192 targets = ?self.config.targets,
193 "Build phase (cross-compilation handled by CI)"
194 );
195
196 if self.config.dry_run {
197 info!(
198 "[dry-run] Would build for {} targets",
199 self.config.targets.len()
200 );
201 }
202
203 Ok(ReleaseReport::empty(ReleasePhase::Build))
204 }
205
206 #[allow(clippy::unused_async, clippy::too_many_lines)]
208 async fn package(&self) -> Result<ReleaseReport> {
209 info!(
210 targets = ?self.config.targets,
211 output_dir = %self.config.output_dir.display(),
212 "Packaging artifacts"
213 );
214
215 if !self.config.dry_run {
217 std::fs::create_dir_all(&self.config.output_dir).map_err(|e| {
218 Error::artifact(
219 format!("Failed to create output directory: {e}"),
220 Some(self.config.output_dir.clone()),
221 )
222 })?;
223 }
224
225 let mut packaged_artifacts = Vec::new();
226 let mut checksums = ChecksumsManifest::new();
227
228 let builder = ArtifactBuilder::new(
229 &self.config.output_dir,
230 &self.config.version,
231 &self.config.name,
232 );
233
234 for target in &self.config.targets {
235 let binary_path = self.find_binary_for_target(*target)?;
236
237 if self.config.dry_run {
238 info!(
239 target = %target.short_id(),
240 binary = %binary_path.display(),
241 "[dry-run] Would package artifact"
242 );
243 continue;
244 }
245
246 let artifact = Artifact {
247 target: *target,
248 binary_path,
249 name: self.config.name.clone(),
250 };
251
252 let packaged = builder.package(&artifact)?;
253
254 debug!(
255 archive = %packaged.archive_name,
256 sha256 = %packaged.sha256,
257 "Created artifact"
258 );
259
260 checksums.add(&packaged.archive_name, &packaged.sha256);
261 packaged_artifacts.push(packaged);
262 }
263
264 if !self.config.dry_run && !packaged_artifacts.is_empty() {
266 let checksums_path = self.config.output_dir.join("CHECKSUMS.txt");
267 checksums.write(&checksums_path)?;
268 info!(path = %checksums_path.display(), "Wrote checksums file");
269 }
270
271 Ok(ReleaseReport::with_artifacts(
272 ReleasePhase::Package,
273 packaged_artifacts,
274 ))
275 }
276
277 async fn publish_only(&self) -> Result<ReleaseReport> {
279 let artifacts = self.load_existing_artifacts()?;
281
282 if artifacts.is_empty() {
283 warn!("No artifacts found to publish");
284 return Ok(ReleaseReport::empty(ReleasePhase::Publish));
285 }
286
287 self.publish_artifacts(&artifacts).await
288 }
289
290 async fn full_pipeline(&self) -> Result<ReleaseReport> {
292 self.build().await?;
294
295 let package_report = self.package().await?;
297
298 let mut report = self.publish_artifacts(&package_report.artifacts).await?;
300 report.phase = ReleasePhase::Full;
301 report.artifacts = package_report.artifacts;
302
303 Ok(report)
304 }
305
306 #[allow(clippy::too_many_lines)] async fn publish_artifacts(&self, artifacts: &[PackagedArtifact]) -> Result<ReleaseReport> {
309 if self.backends.is_empty() {
310 warn!("No backends configured");
311 return Ok(ReleaseReport::empty(ReleasePhase::Publish));
312 }
313
314 let ctx = BackendContext::new(&self.config.name, &self.config.version)
315 .with_dry_run(self.config.dry_run);
316
317 let ctx = if let Some(url) = &self.config.download_base_url {
318 ctx.with_download_url(url)
319 } else {
320 ctx
321 };
322
323 info!(
324 backend_count = self.backends.len(),
325 artifact_count = artifacts.len(),
326 dry_run = self.config.dry_run,
327 "Publishing to backends"
328 );
329
330 let mut results = Vec::new();
331
332 for backend in &self.backends {
333 info!(backend = backend.name(), "Publishing to backend");
334
335 match backend.publish(&ctx, artifacts).await {
336 Ok(result) => {
337 if result.success {
338 info!(
339 backend = backend.name(),
340 message = %result.message,
341 url = ?result.url,
342 "Backend publish succeeded"
343 );
344 } else {
345 warn!(
346 backend = backend.name(),
347 message = %result.message,
348 "Backend publish failed"
349 );
350 }
351 results.push(result);
352 }
353 Err(e) => {
354 warn!(
355 backend = backend.name(),
356 error = %e,
357 "Backend publish error"
358 );
359 results.push(PublishResult::failure(backend.name(), e.to_string()));
360 }
361 }
362 }
363
364 let mut report = ReleaseReport::empty(ReleasePhase::Publish);
365 report.add_backend_results(results);
366
367 Ok(report)
368 }
369
370 fn find_binary_for_target(&self, target: Target) -> Result<PathBuf> {
372 let target_dir = PathBuf::from("target")
374 .join(target.rust_triple())
375 .join("release")
376 .join(&self.config.name);
377
378 if target_dir.exists() {
379 return Ok(target_dir);
380 }
381
382 let native_path = PathBuf::from("target")
384 .join("release")
385 .join(&self.config.name);
386
387 if native_path.exists() {
388 return Ok(native_path);
389 }
390
391 Err(Error::artifact(
392 format!(
393 "Binary not found for target {}. Expected at {} or {}",
394 target.short_id(),
395 target_dir.display(),
396 native_path.display()
397 ),
398 None,
399 ))
400 }
401
402 #[allow(clippy::too_many_lines)] fn load_existing_artifacts(&self) -> Result<Vec<PackagedArtifact>> {
405 let mut artifacts = Vec::new();
406
407 let checksums_path = self.config.output_dir.join("CHECKSUMS.txt");
409 let checksums: std::collections::HashMap<String, String> = if checksums_path.exists() {
410 let content = std::fs::read_to_string(&checksums_path).map_err(|e| {
411 Error::artifact(
412 format!("Failed to read checksums: {e}"),
413 Some(checksums_path.clone()),
414 )
415 })?;
416
417 content
418 .lines()
419 .filter_map(|line| {
420 let parts: Vec<&str> = line.split_whitespace().collect();
421 if parts.len() >= 2 {
422 Some((parts[1].to_string(), parts[0].to_string()))
423 } else {
424 None
425 }
426 })
427 .collect()
428 } else {
429 std::collections::HashMap::new()
430 };
431
432 for target in &self.config.targets {
434 let archive_name = format!(
435 "{}-{}-{}-{}.tar.gz",
436 self.config.name,
437 self.config.version,
438 target.os(),
439 target.arch()
440 );
441 let archive_path = self.config.output_dir.join(&archive_name);
442 let checksum_path = self
443 .config
444 .output_dir
445 .join(format!("{archive_name}.sha256"));
446
447 if archive_path.exists() {
448 let sha256 = checksums
449 .get(&archive_name)
450 .cloned()
451 .unwrap_or_else(|| "unknown".to_string());
452
453 artifacts.push(PackagedArtifact {
454 target: *target,
455 archive_path,
456 checksum_path,
457 archive_name,
458 sha256,
459 });
460 }
461 }
462
463 Ok(artifacts)
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
476 fn test_release_phase_debug() {
477 assert_eq!(format!("{:?}", ReleasePhase::Build), "Build");
478 assert_eq!(format!("{:?}", ReleasePhase::Package), "Package");
479 assert_eq!(format!("{:?}", ReleasePhase::Publish), "Publish");
480 assert_eq!(format!("{:?}", ReleasePhase::Full), "Full");
481 }
482
483 #[test]
484 fn test_release_phase_clone() {
485 let phase = ReleasePhase::Package;
486 let cloned = phase;
487 assert_eq!(phase, cloned);
488 }
489
490 #[test]
491 fn test_release_phase_equality() {
492 assert_eq!(ReleasePhase::Build, ReleasePhase::Build);
493 assert_ne!(ReleasePhase::Build, ReleasePhase::Package);
494 assert_ne!(ReleasePhase::Publish, ReleasePhase::Full);
495 }
496
497 #[test]
502 fn test_orchestrator_config_builder() {
503 let config = OrchestratorConfig::new("test", "1.0.0")
504 .with_targets(vec![Target::LinuxX64])
505 .with_output_dir("dist")
506 .with_dry_run(true)
507 .with_download_url("https://example.com/releases");
508
509 assert_eq!(config.name, "test");
510 assert_eq!(config.version, "1.0.0");
511 assert_eq!(config.targets.len(), 1);
512 assert_eq!(config.output_dir, PathBuf::from("dist"));
513 assert!(config.dry_run);
514 assert_eq!(
515 config.download_base_url,
516 Some("https://example.com/releases".to_string())
517 );
518 }
519
520 #[test]
521 fn test_orchestrator_config_default_targets() {
522 let config = OrchestratorConfig::new("myapp", "2.0.0");
523
524 assert_eq!(config.name, "myapp");
525 assert_eq!(config.version, "2.0.0");
526 assert_eq!(config.targets.len(), 3);
527 assert!(config.targets.contains(&Target::LinuxX64));
528 assert!(config.targets.contains(&Target::LinuxArm64));
529 assert!(config.targets.contains(&Target::DarwinArm64));
530 assert_eq!(config.output_dir, PathBuf::from("target/release-artifacts"));
531 assert!(!config.dry_run);
532 assert!(config.download_base_url.is_none());
533 }
534
535 #[test]
536 fn test_orchestrator_config_clone() {
537 let config = OrchestratorConfig::new("app", "1.0.0").with_dry_run(true);
538 let cloned = config.clone();
539
540 assert_eq!(cloned.name, "app");
541 assert_eq!(cloned.version, "1.0.0");
542 assert!(cloned.dry_run);
543 }
544
545 #[test]
546 fn test_orchestrator_config_debug() {
547 let config = OrchestratorConfig::new("test", "1.0.0");
548 let debug_str = format!("{config:?}");
549 assert!(debug_str.contains("test"));
550 assert!(debug_str.contains("1.0.0"));
551 }
552
553 #[test]
554 fn test_orchestrator_config_empty_targets() {
555 let config = OrchestratorConfig::new("app", "1.0.0").with_targets(vec![]);
556 assert!(config.targets.is_empty());
557 }
558
559 #[test]
560 fn test_orchestrator_config_into_string_conversion() {
561 let config = OrchestratorConfig::new(String::from("myapp"), String::from("3.0.0"));
563 assert_eq!(config.name, "myapp");
564 assert_eq!(config.version, "3.0.0");
565 }
566
567 #[test]
572 fn test_release_report_creation() {
573 let report = ReleaseReport::empty(ReleasePhase::Build);
574 assert_eq!(report.phase, ReleasePhase::Build);
575 assert!(report.artifacts.is_empty());
576 assert!(report.success);
577 }
578
579 #[test]
580 fn test_release_report_empty_all_phases() {
581 for phase in [
582 ReleasePhase::Build,
583 ReleasePhase::Package,
584 ReleasePhase::Publish,
585 ReleasePhase::Full,
586 ] {
587 let report = ReleaseReport::empty(phase);
588 assert_eq!(report.phase, phase);
589 assert!(report.artifacts.is_empty());
590 assert!(report.backend_results.is_empty());
591 assert!(report.success);
592 }
593 }
594
595 #[test]
596 fn test_release_report_with_artifacts() {
597 let artifacts = vec![PackagedArtifact {
598 target: Target::LinuxX64,
599 archive_path: PathBuf::from("/tmp/test.tar.gz"),
600 checksum_path: PathBuf::from("/tmp/test.tar.gz.sha256"),
601 archive_name: "test-1.0.0-linux-x86_64.tar.gz".to_string(),
602 sha256: "abc123".to_string(),
603 }];
604
605 let report = ReleaseReport::with_artifacts(ReleasePhase::Package, artifacts.clone());
606 assert_eq!(report.phase, ReleasePhase::Package);
607 assert_eq!(report.artifacts.len(), 1);
608 assert!(report.backend_results.is_empty());
609 assert!(report.success);
610 }
611
612 #[test]
613 fn test_release_report_add_backend_results_all_success() {
614 let mut report = ReleaseReport::empty(ReleasePhase::Publish);
615 let results = vec![
616 PublishResult::success("github", "Published to GitHub"),
617 PublishResult::success("homebrew", "Published to Homebrew"),
618 ];
619
620 report.add_backend_results(results);
621 assert!(report.success);
622 assert_eq!(report.backend_results.len(), 2);
623 }
624
625 #[test]
626 fn test_release_report_add_backend_results_with_failure() {
627 let mut report = ReleaseReport::empty(ReleasePhase::Publish);
628 let results = vec![
629 PublishResult::success("github", "Published"),
630 PublishResult::failure("homebrew", "Failed to publish"),
631 ];
632
633 report.add_backend_results(results);
634 assert!(!report.success);
635 assert_eq!(report.backend_results.len(), 2);
636 }
637
638 #[test]
639 fn test_release_report_clone() {
640 let report = ReleaseReport::empty(ReleasePhase::Full);
641 let cloned = report.clone();
642 assert_eq!(cloned.phase, ReleasePhase::Full);
643 assert!(cloned.success);
644 }
645
646 #[test]
647 fn test_release_report_debug() {
648 let report = ReleaseReport::empty(ReleasePhase::Build);
649 let debug_str = format!("{report:?}");
650 assert!(debug_str.contains("Build"));
651 assert!(debug_str.contains("success"));
652 }
653
654 #[test]
659 fn test_orchestrator_no_backends() {
660 let config = OrchestratorConfig::new("test", "1.0.0");
661 let orchestrator = ReleaseOrchestrator::new(config);
662 assert!(orchestrator.backends.is_empty());
663 }
664
665 #[test]
666 fn test_orchestrator_config_accessor() {
667 let config = OrchestratorConfig::new("myapp", "2.0.0").with_dry_run(true);
668 let orchestrator = ReleaseOrchestrator::new(config);
669
670 let retrieved_config = orchestrator.config();
671 assert_eq!(retrieved_config.name, "myapp");
672 assert_eq!(retrieved_config.version, "2.0.0");
673 assert!(retrieved_config.dry_run);
674 }
675
676 #[test]
677 fn test_orchestrator_find_binary_for_target_not_found() {
678 let config = OrchestratorConfig::new("nonexistent", "1.0.0");
679 let orchestrator = ReleaseOrchestrator::new(config);
680
681 let result = orchestrator.find_binary_for_target(Target::LinuxX64);
682 assert!(result.is_err());
683 let err = result.unwrap_err();
684 assert!(err.to_string().contains("Binary not found"));
685 }
686
687 #[test]
688 fn test_orchestrator_load_existing_artifacts_empty() {
689 use tempfile::TempDir;
690 let temp = TempDir::new().unwrap();
691
692 let config = OrchestratorConfig::new("test", "1.0.0")
693 .with_output_dir(temp.path().to_path_buf())
694 .with_targets(vec![Target::LinuxX64]);
695
696 let orchestrator = ReleaseOrchestrator::new(config);
697 let artifacts = orchestrator.load_existing_artifacts().unwrap();
698 assert!(artifacts.is_empty());
699 }
700
701 #[test]
702 fn test_orchestrator_load_existing_artifacts_with_checksums() {
703 use tempfile::TempDir;
704 let temp = TempDir::new().unwrap();
705
706 let archive_name = "test-1.0.0-linux-x86_64.tar.gz";
708 let archive_path = temp.path().join(archive_name);
709 std::fs::write(&archive_path, b"test content").unwrap();
710
711 let checksums_content = format!("abc123def456 {archive_name}");
713 std::fs::write(temp.path().join("CHECKSUMS.txt"), checksums_content).unwrap();
714
715 let config = OrchestratorConfig::new("test", "1.0.0")
716 .with_output_dir(temp.path().to_path_buf())
717 .with_targets(vec![Target::LinuxX64]);
718
719 let orchestrator = ReleaseOrchestrator::new(config);
720 let artifacts = orchestrator.load_existing_artifacts().unwrap();
721
722 assert_eq!(artifacts.len(), 1);
723 assert_eq!(artifacts[0].archive_name, archive_name);
724 assert_eq!(artifacts[0].sha256, "abc123def456");
725 }
726
727 #[test]
728 fn test_orchestrator_load_existing_artifacts_no_checksums() {
729 use tempfile::TempDir;
730 let temp = TempDir::new().unwrap();
731
732 let archive_name = "test-1.0.0-linux-x86_64.tar.gz";
734 let archive_path = temp.path().join(archive_name);
735 std::fs::write(&archive_path, b"test content").unwrap();
736
737 let config = OrchestratorConfig::new("test", "1.0.0")
738 .with_output_dir(temp.path().to_path_buf())
739 .with_targets(vec![Target::LinuxX64]);
740
741 let orchestrator = ReleaseOrchestrator::new(config);
742 let artifacts = orchestrator.load_existing_artifacts().unwrap();
743
744 assert_eq!(artifacts.len(), 1);
745 assert_eq!(artifacts[0].sha256, "unknown");
746 }
747
748 #[tokio::test]
749 async fn test_orchestrator_build_dry_run() {
750 let config = OrchestratorConfig::new("test", "1.0.0")
751 .with_dry_run(true)
752 .with_targets(vec![Target::LinuxX64]);
753
754 let orchestrator = ReleaseOrchestrator::new(config);
755 let report = orchestrator.run(ReleasePhase::Build).await.unwrap();
756
757 assert_eq!(report.phase, ReleasePhase::Build);
758 assert!(report.success);
759 assert!(report.artifacts.is_empty());
760 }
761
762 #[tokio::test]
763 async fn test_orchestrator_publish_no_artifacts() {
764 use tempfile::TempDir;
765 let temp = TempDir::new().unwrap();
766
767 let config = OrchestratorConfig::new("test", "1.0.0")
768 .with_output_dir(temp.path().to_path_buf())
769 .with_targets(vec![]);
770
771 let orchestrator = ReleaseOrchestrator::new(config);
772 let report = orchestrator.run(ReleasePhase::Publish).await.unwrap();
773
774 assert_eq!(report.phase, ReleasePhase::Publish);
775 assert!(report.success);
776 assert!(report.artifacts.is_empty());
777 }
778
779 #[tokio::test]
780 async fn test_orchestrator_publish_no_backends() {
781 use tempfile::TempDir;
782 let temp = TempDir::new().unwrap();
783
784 let archive_name = "test-1.0.0-linux-x86_64.tar.gz";
786 std::fs::write(temp.path().join(archive_name), b"test content").unwrap();
787
788 let config = OrchestratorConfig::new("test", "1.0.0")
789 .with_output_dir(temp.path().to_path_buf())
790 .with_targets(vec![Target::LinuxX64]);
791
792 let orchestrator = ReleaseOrchestrator::new(config);
793 let report = orchestrator.run(ReleasePhase::Publish).await.unwrap();
794
795 assert!(report.backend_results.is_empty());
797 }
798
799 #[tokio::test]
800 async fn test_orchestrator_package_dry_run() {
801 use tempfile::TempDir;
802 let temp = TempDir::new().unwrap();
803
804 let config = OrchestratorConfig::new("test", "1.0.0")
805 .with_dry_run(true)
806 .with_output_dir(temp.path().to_path_buf())
807 .with_targets(vec![Target::LinuxX64]);
808
809 let orchestrator = ReleaseOrchestrator::new(config);
810 let report = orchestrator.run(ReleasePhase::Package).await;
811
812 assert!(report.is_err() || report.unwrap().artifacts.is_empty());
815 }
816}