use std::path::PathBuf;
use std::time::Duration;
use crate::contract::schema::Adapter;
use crate::protocol::release::{
BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
};
use super::{
make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, HomebrewFormula, ReleaseAdapter,
SourceTarball,
};
pub struct HomebrewAdapter {
adapter: Adapter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FormulaPath {
Create,
Bump,
}
impl HomebrewAdapter {
#[must_use]
pub fn new(adapter: Adapter) -> Self {
debug_assert!(matches!(
adapter,
Adapter::HomebrewTap | Adapter::HomebrewCore
));
Self { adapter }
}
fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
if self.adapter != Adapter::HomebrewTap {
return None;
}
artifacts.and_then(|h| h.tap.as_deref())
}
fn resolve_path(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<FormulaPath, AdapterError> {
let homebrew = ctx.artifacts.homebrew.as_ref();
match self.tap(homebrew) {
Some(tap) if !Self::formula_exists(ctx, tap, &t.package)? => Ok(FormulaPath::Create),
_ => Ok(FormulaPath::Bump),
}
}
fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
let out = ctx
.runner
.run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
.map_err(|e| AdapterError::Io {
command: cmd.rendered(),
source: e.to_string(),
})?;
if out.status == Some(0) {
return Ok(true);
}
if out.stderr.contains("404") || out.stdout.contains("404") {
return Ok(false);
}
let detail = if out.stderr.trim().is_empty() {
out.stdout
} else {
out.stderr
};
Err(AdapterError::Command {
command: cmd.rendered(),
code: out.status,
stderr: detail,
})
}
fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
let mut args: Vec<String> = match self.adapter {
Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
_ => vec!["bump-formula-pr".into()],
};
if let Some(tarball) = tarball {
args.push("--url".into());
args.push(tarball.url.clone());
if let Some(sha256) = &tarball.sha256 {
args.push("--sha256".into());
args.push(sha256.clone());
}
}
args.push("--".into());
args.push(name.to_string());
PlannedCommand {
program: "brew".into(),
args,
}
}
fn fresh_workdir(name: &str, version: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!(
"ossctl-homebrew-{name}-{version}-{}-{nanos}",
std::process::id()
))
}
fn create_branch(name: &str, version: &str) -> String {
format!("ossctl-homebrew-{name}-{version}")
}
fn create_title(name: &str, version: &str) -> String {
format!("{name} {version} (new formula)")
}
fn create_commands(
tap: &str,
name: &str,
version: &str,
workdir: &str,
sha256_present: bool,
) -> Vec<PlannedCommand> {
let branch = Self::create_branch(name, version);
let title = Self::create_title(name, version);
let formula_rel = format!("Formula/{name}.rb");
let body = if sha256_present {
"Automated first-formula bootstrap by ossctl.".to_string()
} else {
"Automated first-formula bootstrap by ossctl.\n\n**Blocked:** the \
`sha256` of the published release tarball is not yet known at cut \
time (the tag archive does not exist until after publish). Fill in \
the `sha256` once the tag is pushed, then mark this PR ready."
.to_string()
};
let mut pr = vec![
"pr".to_string(),
"create".to_string(),
"--repo".to_string(),
tap.to_string(),
"--head".to_string(),
branch.clone(),
"--title".to_string(),
title.clone(),
"--body".to_string(),
body,
];
if !sha256_present {
pr.push("--draft".to_string());
}
vec![
PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
PlannedCommand::new(
"git",
&[
"-C",
workdir,
"-c",
"user.name=ossctl",
"-c",
"user.email=ossctl@users.noreply.github.com",
"commit",
"-m",
&title,
],
),
PlannedCommand::new(
"git",
&["-C", workdir, "push", "--set-upstream", "origin", &branch],
),
PlannedCommand {
program: "gh".to_string(),
args: pr,
},
]
}
fn run_create(
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
tap: &str,
) -> Result<PublishReceipt, AdapterError> {
let tarball =
ctx.artifacts
.source_tarball
.as_ref()
.ok_or_else(|| AdapterError::Command {
command: "homebrew first-formula".into(),
code: None,
stderr: "cannot generate a first Homebrew formula without a resolvable GitHub \
source-tarball URL (no `origin` GitHub remote?)"
.into(),
})?;
let license = ctx
.artifacts
.homebrew
.as_ref()
.and_then(|h| h.license.as_deref());
let homepage_slug = ctx.artifacts.repo_slug.as_deref();
let formula = render_formula(
&t.package,
homepage_slug,
&tarball.url,
tarball.sha256.as_deref(),
license,
);
let workdir = Self::fresh_workdir(&t.package, &t.version);
let workdir_str = workdir.to_string_lossy().to_string();
let commands = Self::create_commands(
tap,
&t.package,
&t.version,
&workdir_str,
tarball.sha256.is_some(),
);
run_all(ctx, &commands[..1])?;
Self::write_formula(&workdir, &t.package, &formula)?;
let outputs = run_all(ctx, &commands[1..])?;
let remote_url = outputs.last().and_then(|o| {
o.stdout
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with("https://"))
.map(str::to_string)
});
Ok(make_receipt(ctx, t, None, remote_url))
}
fn write_formula(
workdir: &std::path::Path,
name: &str,
formula: &str,
) -> Result<(), AdapterError> {
let dir = workdir.join("Formula");
std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
path: dir.to_string_lossy().to_string(),
source: e.to_string(),
})?;
let path = dir.join(format!("{name}.rb"));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|e| AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: e.to_string(),
})?;
std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: e.to_string(),
}
})
}
}
impl ReleaseAdapter for HomebrewAdapter {
fn adapter(&self) -> Adapter {
self.adapter
}
fn dry_run(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<DryRunReport, AdapterError> {
let tarball = ctx.artifacts.source_tarball.as_ref();
let (planned_commands, mut notes) = match self.resolve_path(ctx, t)? {
FormulaPath::Create => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.unwrap_or_default();
let workdir = Self::fresh_workdir(&t.package, &t.version);
let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
(
Self::create_commands(
tap,
&t.package,
&t.version,
&workdir.to_string_lossy(),
sha256_present,
),
vec![format!(
"create path: `{}` has no `{}.rb` yet — generating the initial \
source-build formula and opening a{} PR",
tap,
t.package,
if sha256_present { "" } else { " draft" }
)],
)
}
FormulaPath::Bump => (
vec![self.bump_command(tarball, &t.package)],
vec!["bump path: the formula already exists — bumping its url/sha256".to_string()],
),
};
match tarball {
Some(tb) => {
let sha = tb
.sha256
.as_deref()
.unwrap_or("(computed by brew from --url)");
notes.push(format!("url: {} ; sha256: {sha}", tb.url));
}
None => notes
.push("source tarball url is resolved by the coordinator at cut time".to_string()),
}
Ok(DryRunReport {
adapter: self.adapter,
planned_commands,
notes,
})
}
fn build(
&self,
_ctx: &EffectCtx<'_>,
_t: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError> {
Ok(BuildArtifacts {
adapter: self.adapter,
artifacts: vec![],
notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
})
}
fn publish(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError> {
match self.resolve_path(ctx, t)? {
FormulaPath::Create => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.expect("resolve_path returns Create only when a tap is configured");
Self::run_create(ctx, t, tap)
}
FormulaPath::Bump => {
let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
run_all(ctx, &[cmd])?;
Ok(make_receipt(ctx, t, None, None))
}
}
}
fn verify(
&self,
_ctx: &EffectCtx<'_>,
_receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
Ok(VerifyOutcome::Unknown)
}
fn timeout(&self) -> Duration {
Duration::from_secs(600)
}
}
fn render_formula(
name: &str,
homepage_slug: Option<&str>,
url: &str,
sha256: Option<&str>,
license: Option<&str>,
) -> String {
let class = formula_class(name);
let name_lit = ruby_escape(name);
let homepage = homepage_slug.map_or_else(
|| ruby_escape(url),
|s| ruby_escape(&format!("https://github.com/{s}")),
);
let url_lit = ruby_escape(url);
let sha_line = match sha256 {
Some(sha) => format!(" sha256 \"{}\"", ruby_escape(sha)),
None => " # TODO: sha256 of the published release tarball \
(unavailable at cut time — fill in after the tag archive exists)"
.to_string(),
};
let license_line = license
.map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
.unwrap_or_default();
format!(
"class {class} < Formula\n\
\x20 desc \"{name_lit}\"\n\
\x20 homepage \"{homepage}\"\n\
\x20 url \"{url_lit}\"\n\
{sha_line}\n\
{license_line}\
\n\
\x20 depends_on \"rust\" => :build\n\
\n\
\x20 def install\n\
\x20 system \"cargo\", \"install\", *std_cargo_args\n\
\x20 end\n\
\n\
\x20 test do\n\
\x20 system bin/\"{name_lit}\", \"--version\"\n\
\x20 end\n\
end\n"
)
}
fn ruby_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn formula_class(name: &str) -> String {
let mut out = String::new();
for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
let mut chars = segment.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
if out.is_empty() {
return "Formula".to_string();
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, 'X');
}
out
}