Skip to main content

cuenv_release/
orchestrator.rs

1//! Release orchestrator.
2//!
3//! Coordinates the full release pipeline: building, packaging, and publishing
4//! to all configured backends.
5
6use 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/// Release phase to execute.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ReleasePhase {
16    /// Build binaries for all targets.
17    Build,
18    /// Package binaries into tarballs with checksums.
19    Package,
20    /// Publish to all backends.
21    Publish,
22    /// Full pipeline: build, package, publish.
23    Full,
24}
25
26/// Configuration for the release orchestrator.
27#[derive(Debug, Clone)]
28pub struct OrchestratorConfig {
29    /// Project/binary name.
30    pub name: String,
31    /// Version being released.
32    pub version: String,
33    /// Target platforms to build for.
34    pub targets: Vec<Target>,
35    /// Output directory for artifacts.
36    pub output_dir: PathBuf,
37    /// Dry run mode (no actual publishing).
38    pub dry_run: DryRun,
39    /// Base URL for downloading release assets.
40    pub download_base_url: Option<String>,
41}
42
43impl OrchestratorConfig {
44    /// Creates a new orchestrator configuration.
45    #[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    /// Sets the target platforms.
58    #[must_use]
59    pub fn with_targets(mut self, targets: Vec<Target>) -> Self {
60        self.targets = targets;
61        self
62    }
63
64    /// Sets the output directory.
65    #[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    /// Sets dry-run mode.
72    #[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    /// Sets the download base URL.
79    #[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/// Report from a release operation.
87#[derive(Debug, Clone)]
88pub struct ReleaseReport {
89    /// Phase that was executed.
90    pub phase: ReleasePhase,
91    /// Packaged artifacts (if package or full phase).
92    pub artifacts: Vec<PackagedArtifact>,
93    /// Results from each backend (if publish or full phase).
94    pub backend_results: Vec<PublishResult>,
95    /// Overall success status.
96    pub success: bool,
97}
98
99impl ReleaseReport {
100    /// Creates an empty report.
101    #[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    /// Creates a report with artifacts.
112    #[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    /// Adds backend results to the report.
123    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
129/// Release orchestrator.
130///
131/// Coordinates the full release pipeline across multiple backends.
132pub struct ReleaseOrchestrator {
133    config: OrchestratorConfig,
134    backends: Vec<Box<dyn ReleaseBackend>>,
135}
136
137impl ReleaseOrchestrator {
138    /// Creates a new release orchestrator.
139    #[must_use]
140    pub fn new(config: OrchestratorConfig) -> Self {
141        Self {
142            config,
143            backends: Vec::new(),
144        }
145    }
146
147    /// Adds a backend to the orchestrator.
148    #[must_use]
149    pub fn with_backend(mut self, backend: Box<dyn ReleaseBackend>) -> Self {
150        self.backends.push(backend);
151        self
152    }
153
154    /// Adds multiple backends to the orchestrator.
155    #[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    /// Returns a reference to the configuration.
162    #[must_use]
163    pub const fn config(&self) -> &OrchestratorConfig {
164        &self.config
165    }
166
167    /// Executes the specified release phase.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if:
172    /// - Binary not found for a target
173    /// - Failed to create output directory
174    /// - Failed to package artifacts
175    /// - Backend publish failed
176    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    /// Builds binaries for all targets.
186    ///
187    /// This phase compiles the project for each target platform.
188    /// Currently a placeholder - actual cross-compilation is handled
189    /// by CI matrix builds.
190    #[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    /// Packages binaries into tarballs with checksums.
208    #[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        // Ensure output directory exists
217        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        // Write checksums file
266        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    /// Publishes existing artifacts to all backends.
279    async fn publish_only(&self) -> Result<ReleaseReport> {
280        // Load existing artifacts from output directory
281        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    /// Runs the full pipeline: build, package, publish.
292    async fn full_pipeline(&self) -> Result<ReleaseReport> {
293        // Build phase
294        self.build().await?;
295
296        // Package phase
297        let package_report = self.package().await?;
298
299        // Publish phase
300        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    /// Publishes artifacts to all configured backends.
308    #[allow(clippy::too_many_lines)] // Multi-backend publishing has multiple iterations
309    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    /// Finds the binary for a given target.
372    fn find_binary_for_target(&self, target: Target) -> Result<PathBuf> {
373        // Standard Rust target directory structure
374        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        // Try without target triple (native build)
384        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    /// Loads existing packaged artifacts from the output directory.
404    #[allow(clippy::too_many_lines)] // Artifact loading has multiple file operations
405    fn load_existing_artifacts(&self) -> Result<Vec<PackagedArtifact>> {
406        let mut artifacts = Vec::new();
407
408        // Load checksums first
409        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        // Find all tarballs
434        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    // ==========================================================================
473    // ReleasePhase tests
474    // ==========================================================================
475
476    #[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    // ==========================================================================
499    // OrchestratorConfig tests
500    // ==========================================================================
501
502    #[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        // Test that Into<String> works for name and version
563        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    // ==========================================================================
569    // ReleaseReport tests
570    // ==========================================================================
571
572    #[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    // ==========================================================================
656    // ReleaseOrchestrator tests
657    // ==========================================================================
658
659    #[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        // Create a test artifact
708        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        // Create checksums file
713        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        // Create a test artifact without checksums file
734        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        // Create a test artifact
786        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        // No backends configured, so no backend results
797        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        // In dry-run mode, package phase skips actual packaging
814        // but may fail if binary not found (expected)
815        assert!(report.is_err() || report.unwrap().artifacts.is_empty());
816    }
817}