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