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