use axoasset::LocalAsset;
use cargo_dist_schema::{ChecksumValue, DistManifest, HomebrewPackageName};
use serde::Serialize;
use spdx::{
expression::{ExprNode, Operator},
Expression, ParseError,
};
use super::InstallerInfo;
use crate::{
backend::templates::TEMPLATE_INSTALLER_RB,
config::{ChecksumStyle, LibraryStyle},
errors::DistResult,
installer::ExecutableZipFragment,
tasks::DistGraph,
};
#[derive(Debug, Clone, Serialize)]
pub struct HomebrewInstallerInfo {
pub name: String,
pub formula_class: String,
pub license: Option<String>,
pub homepage: Option<String>,
pub desc: String,
pub tap: Option<String>,
pub inner: InstallerInfo,
pub dependencies: Vec<HomebrewPackageName>,
pub install_libraries: Vec<LibraryStyle>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HomebrewFragments<T> {
pub x86_64_macos: Option<T>,
pub arm64_macos: Option<T>,
pub x86_64_linux: Option<T>,
pub arm64_linux: Option<T>,
}
pub(crate) fn write_homebrew_formula(
dist: &DistGraph,
info: &HomebrewInstallerInfo,
fragments: &HomebrewFragments<ExecutableZipFragment>,
manifest: &DistManifest,
) -> DistResult<()> {
let info = info.clone();
let checksum_key = ChecksumStyle::Sha256.ext();
let map_fragment = |fragment: ExecutableZipFragment| -> HomebrewFragment {
let sha256 = manifest
.artifacts
.get(&fragment.id)
.and_then(|a| a.checksums.get(checksum_key))
.cloned();
let linkage = manifest.linkage_for_artifact(&fragment.id);
let dependencies = linkage
.homebrew
.iter()
.filter_map(|lib| lib.source.clone())
.collect();
HomebrewFragment {
fragment,
sha256,
dependencies,
}
};
macro_rules! map_fragments {
($fragments:ident = ($($name:ident),*)) => {
let $fragments = HomebrewFragments {
$($name: $fragments.$name.clone().map(map_fragment)),*
};
};
}
map_fragments!(fragments = (arm64_linux, x86_64_linux, arm64_macos, x86_64_macos));
let dest_path = info.inner.dest_path.clone();
let inputs = HomebrewTemplateInputs { info, fragments };
let script = dist
.templates
.render_file_to_clean_string(TEMPLATE_INSTALLER_RB, &inputs)?;
LocalAsset::write_new(&script, dest_path)?;
Ok(())
}
#[derive(Debug, Clone, Serialize)]
struct HomebrewTemplateInputs {
#[serde(flatten)]
info: HomebrewInstallerInfo,
#[serde(flatten)]
fragments: HomebrewFragments<HomebrewFragment>,
}
#[derive(Debug, Clone, Serialize)]
struct HomebrewFragment {
#[serde(flatten)]
fragment: ExecutableZipFragment,
sha256: Option<ChecksumValue>,
dependencies: Vec<String>,
}
pub fn to_class_case(app_name: &str) -> String {
if app_name.is_empty() {
return app_name.to_owned();
}
let mut out = app_name.to_owned();
out[..1].make_ascii_uppercase();
let mut chars = vec![];
let mut iter = out.chars().peekable();
let mut el = iter.next();
let mut at_replaced = false;
while el.is_some() {
let char = el.unwrap();
match char {
'-' | '_' | '.' => {
if let Some(next) = iter.peek() {
if next.is_ascii_digit() || next.is_ascii_alphabetic() {
chars.push(next.to_ascii_uppercase());
iter.next();
} else {
chars.push(char);
}
} else {
chars.push(char);
}
}
'@' => {
if let Some(next) = iter.peek() {
if next.is_ascii_digit() && !at_replaced {
chars.push('A');
chars.push('T');
chars.push(*next);
iter.next();
at_replaced = true;
} else {
chars.push(char);
}
} else {
chars.push(char);
}
}
'+' => {
chars.push('x');
}
_ => chars.push(char),
}
el = iter.next();
}
chars.iter().collect()
}
pub fn to_homebrew_license_format(app_license: &str) -> Result<String, ParseError> {
let spdx = Expression::parse(app_license)?;
let mut spdx = spdx.iter().peekable();
let mut buffer: Vec<String> = vec![];
while let Some(token) = spdx.next() {
match token {
ExprNode::Req(req) => {
let requirement = format!("\"{}\"", req.req);
buffer.push(requirement);
}
ExprNode::Op(op) => {
let second_operand = buffer.pop().expect("Operator missing first operand.");
let first_operand = buffer.pop().expect("Operator missing second operand.");
let mut combined = format!("{}, {}", first_operand, second_operand);
while let Some(ExprNode::Op(next_op)) = spdx.peek() {
if next_op != op {
break;
}
let _ = spdx.next();
let operand = buffer.pop().expect("Operator missing first operand.");
combined = format!("{}, {}", operand, combined);
}
let operation = match op {
Operator::And => "all_of",
Operator::Or => "any_of",
};
let mut enclosed = format!("{operation}: [{combined}]");
if spdx.peek().is_some() {
enclosed = format!("{{ {enclosed} }}");
}
buffer.push(enclosed);
}
}
}
Ok(buffer[0].clone())
}
#[cfg(test)]
mod tests {
use spdx::ParseError;
use super::{to_class_case, to_homebrew_license_format};
fn run_comparison(in_str: &str, expected: &str) {
let out_str = to_class_case(in_str);
assert_eq!(out_str, expected);
}
#[test]
fn class_case_basic() {
run_comparison("ccd2cue", "Ccd2cue");
}
#[test]
fn handles_dashes() {
run_comparison("akaikatana-repack", "AkaikatanaRepack");
}
#[test]
fn handles_single_letter_then_dash() {
run_comparison("c-lang", "CLang");
}
#[test]
fn handles_underscores() {
run_comparison("abc_def", "AbcDef");
}
#[test]
fn handles_strings_with_dots() {
run_comparison("last.fm", "LastFm");
}
#[test]
fn replaces_plus_with_x() {
run_comparison("c++", "Cxx");
}
#[test]
fn replaces_ampersand_with_at() {
run_comparison("openssl@3", "OpensslAT3");
}
#[test]
fn class_caps_after_numbers() {
run_comparison("mni2mz3", "Mni2mz3");
}
#[test]
fn handles_pluralization() {
run_comparison("tetanes", "Tetanes");
}
#[test]
fn multiple_underscores() {
run_comparison("abc__def", "Abc_Def");
}
#[test]
fn multiple_periods() {
run_comparison("abc..def", "Abc.Def");
}
#[test]
fn multiple_special_chars() {
run_comparison("abc-.def", "Abc-Def");
}
#[test]
fn ends_with_dash() {
run_comparison("abc-", "Abc-");
}
#[test]
fn multiple_ampersands() {
run_comparison("openssl@@3", "Openssl@AT3");
}
#[test]
fn ampersand_but_no_digit() {
run_comparison("openssl@blah", "Openssl@blah");
}
fn run_spdx_comparison(spdx_string: &str, homebrew_dsl: &str) {
let result = to_homebrew_license_format(spdx_string).unwrap();
assert_eq!(result, homebrew_dsl);
}
#[test]
fn spdx_single_license() {
run_spdx_comparison("MIT", r#""MIT""#);
}
#[test]
fn spdx_single_license_with_plus() {
run_spdx_comparison("Apache-2.0+", r#""Apache-2.0+""#);
}
#[test]
fn spdx_two_licenses_any() {
run_spdx_comparison("MIT OR 0BSD", r#"any_of: ["MIT", "0BSD"]"#);
}
#[test]
fn spdx_two_licenses_all() {
run_spdx_comparison("MIT AND 0BSD", r#"all_of: ["MIT", "0BSD"]"#);
}
#[test]
fn spdx_two_licenses_with_plus() {
run_spdx_comparison("MIT OR EPL-1.0+", r#"any_of: ["MIT", "EPL-1.0+"]"#);
}
#[test]
fn spdx_three_licenses() {
run_spdx_comparison(
"MIT OR Apache-2.0 OR CC-BY-4.0",
r#"any_of: ["MIT", "Apache-2.0", "CC-BY-4.0"]"#,
);
}
#[test]
fn spdx_three_licenses_or_and() {
run_spdx_comparison(
"MIT OR Apache-2.0 AND CC-BY-4.0",
r#"any_of: ["MIT", { all_of: ["Apache-2.0", "CC-BY-4.0"] }]"#,
);
}
#[test]
fn spdx_three_licenses_and_or() {
run_spdx_comparison(
"MIT AND Apache-2.0 OR CC-BY-4.0",
r#"any_of: [{ all_of: ["MIT", "Apache-2.0"] }, "CC-BY-4.0"]"#,
);
}
#[test]
fn spdx_parentheses() {
run_spdx_comparison(
"MIT OR (0BSD AND Zlib) OR curl",
r#"any_of: ["MIT", { all_of: ["0BSD", "Zlib"] }, "curl"]"#,
);
}
#[test]
fn spdx_nested_parentheses() {
run_spdx_comparison(
"MIT AND (Apache-2.0 OR (CC-BY-4.0 AND 0BSD))",
r#"all_of: ["MIT", { any_of: ["Apache-2.0", { all_of: ["CC-BY-4.0", "0BSD"] }] }]"#,
);
}
fn run_malformed_spdx(spdx_string: &str) {
let result = to_homebrew_license_format(spdx_string);
assert!(matches!(result, Err(ParseError { .. })));
}
#[test]
fn spdx_invalid_license_name() {
run_malformed_spdx("foo");
}
#[test]
fn spdx_invalid_just_operator() {
run_malformed_spdx("AND");
}
#[test]
fn spdx_invalid_dangling_operator() {
run_malformed_spdx("MIT OR");
}
#[test]
fn spdx_invalid_adjacent_operator() {
run_malformed_spdx("MIT AND OR Apache-2.0");
}
}