Skip to main content

cuenv_release/backends/
mod.rs

1//! Release distribution backends.
2//!
3//! This module defines the [`ReleaseBackend`] trait that provider crates
4//! can implement to support release distribution.
5//!
6//! # Architecture
7//!
8//! The release crate provides:
9//! - [`ReleaseBackend`] trait - interface for publishing artifacts
10//! - [`BackendContext`] - common context passed to backends
11//! - [`PublishResult`] - result type for publish operations
12//!
13//! Provider crates implement `ReleaseBackend`:
14//! - `cuenv-github` - GitHub Releases
15//! - `cuenv-homebrew` - Homebrew tap updates
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use cuenv_release::backends::{ReleaseBackend, BackendContext, PublishResult};
21//! use cuenv_release::artifact::PackagedArtifact;
22//!
23//! struct MyBackend;
24//!
25//! impl ReleaseBackend for MyBackend {
26//!     fn name(&self) -> &'static str { "my-backend" }
27//!
28//!     fn publish<'a>(
29//!         &'a self,
30//!         ctx: &'a BackendContext,
31//!         artifacts: &'a [PackagedArtifact],
32//!     ) -> Pin<Box<dyn Future<Output = Result<PublishResult>> + Send + 'a>> {
33//!         Box::pin(async move {
34//!             // Upload artifacts...
35//!             Ok(PublishResult::success("my-backend", "Published"))
36//!         })
37//!     }
38//! }
39//! ```
40
41use crate::artifact::PackagedArtifact;
42use crate::error::Result;
43use cuenv_core::DryRun;
44use std::future::Future;
45use std::pin::Pin;
46
47/// Configuration common to all backends.
48#[derive(Debug, Clone)]
49pub struct BackendContext {
50    /// Project/binary name
51    pub name: String,
52    /// Version being released (without 'v' prefix)
53    pub version: String,
54    /// Whether this is a dry-run (no actual publishing)
55    pub dry_run: DryRun,
56    /// Base URL for downloading release assets (e.g., GitHub releases URL)
57    pub download_base_url: Option<String>,
58}
59
60impl BackendContext {
61    /// Creates a new backend context.
62    #[must_use]
63    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
64        Self {
65            name: name.into(),
66            version: version.into(),
67            dry_run: DryRun::No,
68            download_base_url: None,
69        }
70    }
71
72    /// Sets the dry-run flag.
73    #[must_use]
74    pub const fn with_dry_run(mut self, dry_run: DryRun) -> Self {
75        self.dry_run = dry_run;
76        self
77    }
78
79    /// Sets the download base URL.
80    #[must_use]
81    pub fn with_download_url(mut self, url: impl Into<String>) -> Self {
82        self.download_base_url = Some(url.into());
83        self
84    }
85}
86
87/// Result of a backend publish operation.
88#[derive(Debug, Clone)]
89pub struct PublishResult {
90    /// Name of the backend
91    pub backend: String,
92    /// Whether publishing succeeded
93    pub success: bool,
94    /// URL or identifier of the published artifact (if any)
95    pub url: Option<String>,
96    /// Human-readable message
97    pub message: String,
98}
99
100impl PublishResult {
101    /// Creates a successful result.
102    #[must_use]
103    pub fn success(backend: impl Into<String>, message: impl Into<String>) -> Self {
104        Self {
105            backend: backend.into(),
106            success: true,
107            url: None,
108            message: message.into(),
109        }
110    }
111
112    /// Creates a successful result with URL.
113    #[must_use]
114    pub fn success_with_url(
115        backend: impl Into<String>,
116        message: impl Into<String>,
117        url: impl Into<String>,
118    ) -> Self {
119        Self {
120            backend: backend.into(),
121            success: true,
122            url: Some(url.into()),
123            message: message.into(),
124        }
125    }
126
127    /// Creates a dry-run result.
128    #[must_use]
129    pub fn dry_run(backend: impl Into<String>, message: impl Into<String>) -> Self {
130        Self {
131            backend: backend.into(),
132            success: true,
133            url: None,
134            message: format!("[dry-run] {}", message.into()),
135        }
136    }
137
138    /// Creates a failure result.
139    #[must_use]
140    pub fn failure(backend: impl Into<String>, message: impl Into<String>) -> Self {
141        Self {
142            backend: backend.into(),
143            success: false,
144            url: None,
145            message: message.into(),
146        }
147    }
148}
149
150/// Trait for release distribution backends.
151///
152/// Each backend handles publishing artifacts to a specific distribution channel
153/// (GitHub Releases, Homebrew, crates.io, CUE registry, etc.).
154///
155/// # Implementors
156///
157/// - `cuenv-github` - GitHub Releases backend
158/// - `cuenv-homebrew` - Homebrew tap backend
159///
160/// # Example
161///
162/// See module-level documentation for implementation example.
163pub trait ReleaseBackend: Send + Sync {
164    /// Returns the name of this backend (e.g., "github", "homebrew").
165    fn name(&self) -> &'static str;
166
167    /// Publishes the given artifacts to this backend.
168    ///
169    /// # Arguments
170    /// * `ctx` - Common context (version, dry-run flag, etc.)
171    /// * `artifacts` - Packaged artifacts to publish
172    ///
173    /// # Returns
174    /// A [`PublishResult`] indicating success or failure.
175    fn publish<'a>(
176        &'a self,
177        ctx: &'a BackendContext,
178        artifacts: &'a [PackagedArtifact],
179    ) -> Pin<Box<dyn Future<Output = Result<PublishResult>> + Send + 'a>>;
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_backend_context_new() {
188        let ctx = BackendContext::new("my-app", "1.0.0");
189        assert_eq!(ctx.name, "my-app");
190        assert_eq!(ctx.version, "1.0.0");
191        assert!(!ctx.dry_run.is_dry_run());
192        assert!(ctx.download_base_url.is_none());
193    }
194
195    #[test]
196    fn test_backend_context_with_dry_run() {
197        let ctx = BackendContext::new("my-app", "1.0.0").with_dry_run(DryRun::Yes);
198        assert!(ctx.dry_run.is_dry_run());
199    }
200
201    #[test]
202    fn test_backend_context_with_download_url() {
203        let ctx =
204            BackendContext::new("my-app", "1.0.0").with_download_url("https://github.com/releases");
205        assert_eq!(
206            ctx.download_base_url,
207            Some("https://github.com/releases".to_string())
208        );
209    }
210
211    #[test]
212    fn test_backend_context_builder_chain() {
213        let ctx = BackendContext::new("test", "2.0.0")
214            .with_dry_run(DryRun::Yes)
215            .with_download_url("https://example.com");
216
217        assert_eq!(ctx.name, "test");
218        assert_eq!(ctx.version, "2.0.0");
219        assert!(ctx.dry_run.is_dry_run());
220        assert_eq!(
221            ctx.download_base_url,
222            Some("https://example.com".to_string())
223        );
224    }
225
226    #[test]
227    fn test_publish_result_success() {
228        let result = PublishResult::success("github", "Published successfully");
229        assert!(result.success);
230        assert_eq!(result.backend, "github");
231        assert_eq!(result.message, "Published successfully");
232        assert!(result.url.is_none());
233    }
234
235    #[test]
236    fn test_publish_result_success_with_url() {
237        let result = PublishResult::success_with_url(
238            "github",
239            "Released",
240            "https://github.com/repo/releases/v1.0.0",
241        );
242        assert!(result.success);
243        assert_eq!(
244            result.url,
245            Some("https://github.com/repo/releases/v1.0.0".to_string())
246        );
247    }
248
249    #[test]
250    fn test_publish_result_dry_run() {
251        let result = PublishResult::dry_run("homebrew", "Would update formula");
252        assert!(result.success);
253        assert!(result.message.starts_with("[dry-run]"));
254        assert!(result.message.contains("Would update formula"));
255    }
256
257    #[test]
258    fn test_publish_result_failure() {
259        let result = PublishResult::failure("crates-io", "Upload failed");
260        assert!(!result.success);
261        assert_eq!(result.backend, "crates-io");
262        assert_eq!(result.message, "Upload failed");
263        assert!(result.url.is_none());
264    }
265
266    #[test]
267    fn test_backend_context_debug() {
268        let ctx = BackendContext::new("app", "1.0");
269        let debug_str = format!("{ctx:?}");
270        assert!(debug_str.contains("BackendContext"));
271        assert!(debug_str.contains("app"));
272    }
273
274    #[test]
275    fn test_publish_result_debug() {
276        let result = PublishResult::success("test", "ok");
277        let debug_str = format!("{result:?}");
278        assert!(debug_str.contains("PublishResult"));
279        assert!(debug_str.contains("test"));
280    }
281
282    #[test]
283    fn test_backend_context_clone() {
284        let ctx = BackendContext::new("app", "1.0")
285            .with_dry_run(DryRun::Yes)
286            .with_download_url("https://example.com");
287        let cloned = ctx.clone();
288        assert_eq!(ctx.name, cloned.name);
289        assert_eq!(ctx.version, cloned.version);
290        assert_eq!(ctx.dry_run, cloned.dry_run);
291        assert_eq!(ctx.download_base_url, cloned.download_base_url);
292    }
293
294    #[test]
295    fn test_publish_result_clone() {
296        let result = PublishResult::success_with_url("github", "Released", "https://url");
297        let cloned = result.clone();
298        assert_eq!(result.backend, cloned.backend);
299        assert_eq!(result.success, cloned.success);
300        assert_eq!(result.url, cloned.url);
301        assert_eq!(result.message, cloned.message);
302    }
303}