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