Skip to main content

cuenv_homebrew/
backend.rs

1//! Homebrew backend for cuenv releases.
2//!
3//! Generates and pushes Homebrew formulas to a tap repository.
4
5use crate::formula::{BinaryInfo, FormulaData, FormulaGenerator};
6use cuenv_release::PackagedArtifact;
7use cuenv_release::backends::{BackendContext, PublishResult, ReleaseBackend};
8use cuenv_release::error::Result;
9use octocrab::Octocrab;
10use std::collections::HashMap;
11use std::future::Future;
12use std::pin::Pin;
13use tracing::{debug, info};
14
15/// Configuration for the Homebrew backend.
16#[derive(Debug, Clone)]
17pub struct HomebrewConfig {
18    /// Tap repository in "owner/repo" format (e.g., "cuenv/homebrew-tap")
19    pub tap: String,
20    /// Formula name (defaults to project name)
21    pub formula: String,
22    /// License identifier (e.g., "AGPL-3.0-or-later")
23    pub license: String,
24    /// Project homepage URL
25    pub homepage: String,
26    /// GitHub token for pushing to tap (reads from env if not set)
27    pub token: Option<String>,
28    /// Token environment variable name (default: `HOMEBREW_TAP_TOKEN`)
29    pub token_env: String,
30}
31
32impl HomebrewConfig {
33    /// Creates a new Homebrew configuration.
34    #[must_use]
35    pub fn new(tap: impl Into<String>, formula: impl Into<String>) -> Self {
36        Self {
37            tap: tap.into(),
38            formula: formula.into(),
39            license: String::new(),
40            homepage: String::new(),
41            token: None,
42            token_env: "HOMEBREW_TAP_TOKEN".to_string(),
43        }
44    }
45
46    /// Sets the license.
47    #[must_use]
48    pub fn with_license(mut self, license: impl Into<String>) -> Self {
49        self.license = license.into();
50        self
51    }
52
53    /// Sets the homepage.
54    #[must_use]
55    pub fn with_homepage(mut self, homepage: impl Into<String>) -> Self {
56        self.homepage = homepage.into();
57        self
58    }
59
60    /// Sets the GitHub token directly.
61    #[must_use]
62    pub fn with_token(mut self, token: impl Into<String>) -> Self {
63        self.token = Some(token.into());
64        self
65    }
66
67    /// Sets the token environment variable name.
68    #[must_use]
69    pub fn with_token_env(mut self, env_var: impl Into<String>) -> Self {
70        self.token_env = env_var.into();
71        self
72    }
73
74    /// Gets the token, either from config or environment.
75    fn get_token(&self) -> Option<String> {
76        self.token
77            .clone()
78            .or_else(|| std::env::var(&self.token_env).ok())
79    }
80}
81
82/// Parse a tap string into (owner, repo).
83fn parse_tap(tap: &str) -> Option<(String, String)> {
84    let (owner, repo) = tap.split_once('/')?;
85    Some((owner.to_string(), repo.to_string()))
86}
87
88/// Homebrew backend for updating tap repositories.
89pub struct HomebrewBackend {
90    config: HomebrewConfig,
91}
92
93impl HomebrewBackend {
94    /// Creates a new Homebrew backend.
95    #[must_use]
96    pub const fn new(config: HomebrewConfig) -> Self {
97        Self { config }
98    }
99
100    /// Creates formula data from packaged artifacts.
101    fn create_formula_data(
102        &self,
103        ctx: &BackendContext,
104        artifacts: &[PackagedArtifact],
105    ) -> FormulaData {
106        let mut binaries = HashMap::new();
107
108        let base_url = ctx
109            .download_base_url
110            .as_deref()
111            .unwrap_or("https://github.com/OWNER/REPO/releases/download");
112
113        for artifact in artifacts {
114            let url = format!("{}/v{}/{}", base_url, ctx.version, artifact.archive_name);
115            binaries.insert(
116                artifact.target,
117                BinaryInfo {
118                    url,
119                    sha256: artifact.sha256.clone(),
120                },
121            );
122        }
123
124        // Convert formula name to class name (capitalize first letter)
125        let class_name = self
126            .config
127            .formula
128            .chars()
129            .enumerate()
130            .map(|(i, c)| if i == 0 { c.to_ascii_uppercase() } else { c })
131            .collect();
132
133        FormulaData {
134            class_name,
135            desc: format!("{} - environment management tool", ctx.name),
136            homepage: self.config.homepage.clone(),
137            license: self.config.license.clone(),
138            version: ctx.version.clone(),
139            binaries,
140        }
141    }
142
143    /// Generates the formula content without publishing.
144    #[must_use]
145    pub fn generate_formula(&self, ctx: &BackendContext, artifacts: &[PackagedArtifact]) -> String {
146        let data = self.create_formula_data(ctx, artifacts);
147        FormulaGenerator::generate(&data)
148    }
149
150    /// Creates an authenticated Octocrab client.
151    fn client(&self) -> Result<Octocrab> {
152        let token = self.config.get_token().ok_or_else(|| {
153            cuenv_release::error::Error::backend(
154                "Homebrew",
155                format!(
156                    "No token found. Set {} environment variable or provide token in config",
157                    self.config.token_env
158                ),
159                Some(format!(
160                    "export {}=<your-github-token>",
161                    self.config.token_env
162                )),
163            )
164        })?;
165
166        Octocrab::builder()
167            .personal_token(token)
168            .build()
169            .map_err(|e| cuenv_release::error::Error::backend("Homebrew", e.to_string(), None))
170    }
171
172    /// Pushes the formula to the tap repository.
173    async fn push_formula(
174        &self,
175        client: &Octocrab,
176        formula_content: &str,
177        version: &str,
178    ) -> Result<String> {
179        let (owner, repo) = parse_tap(&self.config.tap).ok_or_else(|| {
180            cuenv_release::error::Error::backend(
181                "Homebrew",
182                format!(
183                    "Invalid tap format: '{}'. Expected 'owner/repo'",
184                    self.config.tap
185                ),
186                None,
187            )
188        })?;
189
190        let path = format!("Formula/{}.rb", self.config.formula);
191        let commit_message = format!("Update {} to {}", self.config.formula, version);
192
193        debug!(
194            owner = %owner,
195            repo = %repo,
196            path = %path,
197            "Pushing formula to tap"
198        );
199
200        // Try to get existing file to get its SHA (needed for updates)
201        let repos = client.repos(&owner, &repo);
202        let existing_sha = match repos.get_content().path(&path).send().await {
203            Ok(content) => content.items.first().map(|item| item.sha.clone()),
204            Err(_) => None,
205        };
206
207        // Encode content as base64
208        let encoded_content = base64::Engine::encode(
209            &base64::engine::general_purpose::STANDARD,
210            formula_content.as_bytes(),
211        );
212
213        // Create or update the file
214        let result = if let Some(sha) = existing_sha {
215            debug!(sha = %sha, "Updating existing formula");
216            repos
217                .update_file(&path, &commit_message, &encoded_content, &sha)
218                .branch("main")
219                .send()
220                .await
221        } else {
222            debug!("Creating new formula");
223            repos
224                .create_file(&path, &commit_message, &encoded_content)
225                .branch("main")
226                .send()
227                .await
228        };
229
230        result
231            .map_err(|e| cuenv_release::error::Error::backend("Homebrew", e.to_string(), None))?;
232
233        let formula_url = format!("https://github.com/{owner}/{repo}/blob/main/{path}");
234
235        info!(url = %formula_url, "Formula pushed to tap");
236
237        Ok(formula_url)
238    }
239}
240
241impl ReleaseBackend for HomebrewBackend {
242    fn name(&self) -> &'static str {
243        "Homebrew"
244    }
245
246    fn publish<'a>(
247        &'a self,
248        ctx: &'a BackendContext,
249        artifacts: &'a [PackagedArtifact],
250    ) -> Pin<Box<dyn Future<Output = Result<PublishResult>> + Send + 'a>> {
251        Box::pin(async move {
252            let formula_content = self.generate_formula(ctx, artifacts);
253
254            debug!(
255                formula_name = %self.config.formula,
256                formula_len = formula_content.len(),
257                "Generated formula"
258            );
259
260            if ctx.dry_run.is_dry_run() {
261                info!(
262                    tap = %self.config.tap,
263                    formula = %self.config.formula,
264                    "Would update Homebrew formula"
265                );
266                return Ok(PublishResult::dry_run(
267                    "Homebrew",
268                    format!(
269                        "Would update formula {} in {}",
270                        self.config.formula, self.config.tap
271                    ),
272                ));
273            }
274
275            let client = self.client()?;
276            let formula_url = self
277                .push_formula(&client, &formula_content, &ctx.version)
278                .await?;
279
280            Ok(PublishResult::success_with_url(
281                "Homebrew",
282                format!(
283                    "Updated formula {} in {}",
284                    self.config.formula, self.config.tap
285                ),
286                formula_url,
287            ))
288        })
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use cuenv_release::{DryRun, Target};
296
297    #[test]
298    fn test_parse_tap() {
299        let result = parse_tap("cuenv/homebrew-tap");
300        assert_eq!(
301            result,
302            Some(("cuenv".to_string(), "homebrew-tap".to_string()))
303        );
304    }
305
306    #[test]
307    fn test_parse_tap_invalid() {
308        assert!(parse_tap("invalid").is_none());
309        assert!(parse_tap("").is_none());
310    }
311
312    #[test]
313    fn test_config_builder() {
314        let config = HomebrewConfig::new("owner/tap", "formula")
315            .with_license("MIT")
316            .with_homepage("https://example.com")
317            .with_token_env("MY_TOKEN");
318
319        assert_eq!(config.tap, "owner/tap");
320        assert_eq!(config.formula, "formula");
321        assert_eq!(config.license, "MIT");
322        assert_eq!(config.homepage, "https://example.com");
323        assert_eq!(config.token_env, "MY_TOKEN");
324    }
325
326    #[test]
327    fn test_config_new_defaults() {
328        let config = HomebrewConfig::new("owner/tap", "myformula");
329        assert_eq!(config.tap, "owner/tap");
330        assert_eq!(config.formula, "myformula");
331        assert!(config.license.is_empty());
332        assert!(config.homepage.is_empty());
333        assert!(config.token.is_none());
334        assert_eq!(config.token_env, "HOMEBREW_TAP_TOKEN");
335    }
336
337    #[test]
338    fn test_config_with_token() {
339        let config = HomebrewConfig::new("owner/tap", "formula").with_token("my-secret-token");
340        assert_eq!(config.token, Some("my-secret-token".to_string()));
341    }
342
343    #[test]
344    fn test_config_clone() {
345        let config = HomebrewConfig::new("owner/tap", "formula")
346            .with_license("MIT")
347            .with_homepage("https://example.com");
348        let cloned = config.clone();
349        assert_eq!(config.tap, cloned.tap);
350        assert_eq!(config.formula, cloned.formula);
351        assert_eq!(config.license, cloned.license);
352    }
353
354    #[test]
355    fn test_config_debug() {
356        let config = HomebrewConfig::new("owner/tap", "formula");
357        let debug_str = format!("{config:?}");
358        assert!(debug_str.contains("HomebrewConfig"));
359        assert!(debug_str.contains("owner/tap"));
360        assert!(debug_str.contains("formula"));
361    }
362
363    #[test]
364    fn test_parse_tap_multiple_slashes() {
365        let result = parse_tap("owner/repo/extra");
366        // Should only split on first slash
367        assert_eq!(
368            result,
369            Some(("owner".to_string(), "repo/extra".to_string()))
370        );
371    }
372
373    #[test]
374    fn test_parse_tap_org_with_dash() {
375        let result = parse_tap("my-org/homebrew-formulas");
376        assert_eq!(
377            result,
378            Some(("my-org".to_string(), "homebrew-formulas".to_string()))
379        );
380    }
381
382    #[test]
383    fn test_backend_name() {
384        let config = HomebrewConfig::new("owner/tap", "formula");
385        let backend = HomebrewBackend::new(config);
386        assert_eq!(backend.name(), "Homebrew");
387    }
388
389    #[test]
390    fn test_generate_formula_with_artifacts() {
391        let config = HomebrewConfig::new("owner/tap", "cuenv")
392            .with_license("AGPL-3.0")
393            .with_homepage("https://cuenv.io");
394        let backend = HomebrewBackend::new(config);
395
396        let ctx = BackendContext {
397            name: "cuenv".to_string(),
398            version: "1.0.0".to_string(),
399            download_base_url: Some("https://github.com/cuenv/cuenv/releases/download".to_string()),
400            dry_run: DryRun::No,
401        };
402
403        let artifacts = vec![PackagedArtifact {
404            target: Target::DarwinArm64,
405            archive_name: "cuenv-darwin-arm64.tar.gz".to_string(),
406            sha256: "abcdef123456".to_string(),
407            archive_path: std::path::PathBuf::from("/tmp/cuenv-darwin-arm64.tar.gz"),
408            checksum_path: std::path::PathBuf::from("/tmp/cuenv-darwin-arm64.tar.gz.sha256"),
409        }];
410
411        let formula = backend.generate_formula(&ctx, &artifacts);
412        assert!(formula.contains("class Cuenv < Formula"));
413        assert!(formula.contains("version \"1.0.0\""));
414        assert!(formula.contains("abcdef123456"));
415        assert!(formula.contains("cuenv-darwin-arm64.tar.gz"));
416    }
417
418    #[test]
419    fn test_generate_formula_default_base_url() {
420        let config = HomebrewConfig::new("owner/tap", "myapp");
421        let backend = HomebrewBackend::new(config);
422
423        let ctx = BackendContext {
424            name: "myapp".to_string(),
425            version: "2.0.0".to_string(),
426            download_base_url: None, // Uses default
427            dry_run: DryRun::No,
428        };
429
430        let artifacts = vec![PackagedArtifact {
431            target: Target::LinuxX64,
432            archive_name: "myapp-linux-x64.tar.gz".to_string(),
433            sha256: "789xyz".to_string(),
434            archive_path: std::path::PathBuf::from("/tmp/myapp.tar.gz"),
435            checksum_path: std::path::PathBuf::from("/tmp/myapp.tar.gz.sha256"),
436        }];
437
438        let formula = backend.generate_formula(&ctx, &artifacts);
439        // Should use default base URL
440        assert!(formula.contains("https://github.com/OWNER/REPO/releases/download"));
441    }
442
443    #[test]
444    fn test_generate_formula_capitalizes_class_name() {
445        let config = HomebrewConfig::new("owner/tap", "myapp");
446        let backend = HomebrewBackend::new(config);
447
448        let ctx = BackendContext {
449            name: "myapp".to_string(),
450            version: "1.0.0".to_string(),
451            download_base_url: None,
452            dry_run: DryRun::No,
453        };
454
455        let formula = backend.generate_formula(&ctx, &[]);
456        assert!(formula.contains("class Myapp < Formula"));
457    }
458
459    #[test]
460    fn test_generate_formula_with_multiple_artifacts() {
461        let config = HomebrewConfig::new("owner/tap", "tool")
462            .with_license("MIT")
463            .with_homepage("https://tool.dev");
464        let backend = HomebrewBackend::new(config);
465
466        let ctx = BackendContext {
467            name: "tool".to_string(),
468            version: "3.0.0".to_string(),
469            download_base_url: Some("https://releases.tool.dev".to_string()),
470            dry_run: DryRun::No,
471        };
472
473        let artifacts = vec![
474            PackagedArtifact {
475                target: Target::DarwinArm64,
476                archive_name: "tool-darwin-arm64.tar.gz".to_string(),
477                sha256: "darwin_hash".to_string(),
478                archive_path: std::path::PathBuf::from("/tmp/tool-darwin.tar.gz"),
479                checksum_path: std::path::PathBuf::from("/tmp/tool-darwin.tar.gz.sha256"),
480            },
481            PackagedArtifact {
482                target: Target::LinuxX64,
483                archive_name: "tool-linux-x64.tar.gz".to_string(),
484                sha256: "linux_hash".to_string(),
485                archive_path: std::path::PathBuf::from("/tmp/tool-linux.tar.gz"),
486                checksum_path: std::path::PathBuf::from("/tmp/tool-linux.tar.gz.sha256"),
487            },
488        ];
489
490        let formula = backend.generate_formula(&ctx, &artifacts);
491        assert!(formula.contains("darwin_hash"));
492        assert!(formula.contains("linux_hash"));
493        assert!(formula.contains("on_macos do"));
494        assert!(formula.contains("on_linux do"));
495    }
496
497    #[test]
498    fn test_config_get_token_from_direct() {
499        let config = HomebrewConfig::new("owner/tap", "formula").with_token("direct-token");
500        assert_eq!(config.get_token(), Some("direct-token".to_string()));
501    }
502
503    #[test]
504    fn test_config_get_token_missing() {
505        // Use a unique env var name that won't be set
506        let config = HomebrewConfig::new("owner/tap", "formula")
507            .with_token_env("CUENV_TEST_HOMEBREW_TOKEN_DEFINITELY_MISSING_12345");
508        assert!(config.get_token().is_none());
509    }
510}