1use 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#[derive(Debug, Clone)]
17pub struct HomebrewConfig {
18 pub tap: String,
20 pub formula: String,
22 pub license: String,
24 pub homepage: String,
26 pub token: Option<String>,
28 pub token_env: String,
30}
31
32impl HomebrewConfig {
33 #[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 #[must_use]
48 pub fn with_license(mut self, license: impl Into<String>) -> Self {
49 self.license = license.into();
50 self
51 }
52
53 #[must_use]
55 pub fn with_homepage(mut self, homepage: impl Into<String>) -> Self {
56 self.homepage = homepage.into();
57 self
58 }
59
60 #[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 #[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 fn get_token(&self) -> Option<String> {
76 self.token
77 .clone()
78 .or_else(|| std::env::var(&self.token_env).ok())
79 }
80}
81
82fn parse_tap(tap: &str) -> Option<(String, String)> {
84 let (owner, repo) = tap.split_once('/')?;
85 Some((owner.to_string(), repo.to_string()))
86}
87
88pub struct HomebrewBackend {
90 config: HomebrewConfig,
91}
92
93impl HomebrewBackend {
94 #[must_use]
96 pub const fn new(config: HomebrewConfig) -> Self {
97 Self { config }
98 }
99
100 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 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 #[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 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 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 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 let encoded_content = base64::Engine::encode(
209 &base64::engine::general_purpose::STANDARD,
210 formula_content.as_bytes(),
211 );
212
213 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 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, 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 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 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}