use std::path::Path;
use serde::Deserialize;
use std::slice;
use crate::manager::{Installed, Manager, Package, Step, capture, lines};
pub struct Brew;
impl Manager for Brew {
fn id(&self) -> &'static str {
"brew"
}
fn name(&self) -> &'static str {
"Homebrew"
}
fn binary(&self) -> &'static str {
"brew"
}
fn supported(&self) -> bool {
cfg!(any(target_os = "macos", target_os = "linux"))
}
fn outdated(&self, bin: &Path) -> Result<Vec<Package>, String> {
let mut args = vec!["outdated", "--json=v2"];
args.extend(greedy());
let json = capture(bin, args)?.expect(&[0, 1])?;
parse_outdated(&json)
}
fn upgrade_steps(&self, bin: &Path, packages: &[Package]) -> Vec<Step> {
let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
let asking = password_casks(bin, &names);
let (password, quiet): (Vec<Package>, Vec<Package>) = packages
.iter()
.cloned()
.partition(|p| asking.contains(&p.name));
let step = |packages: &[Package]| {
let mut args = vec!["upgrade".to_string()];
args.extend(greedy().map(String::from));
args.extend(packages.iter().map(|p| p.name.clone()));
Step::new(args, packages)
};
let mut steps = Vec::new();
if !quiet.is_empty() {
steps.push(step(&quiet));
}
if !password.is_empty() {
steps.push(step(&password).with_password());
}
steps
}
fn hint(&self, packages: &[Package], error: &str) -> Option<String> {
let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
let names = names.join(" ");
if error.contains("App source") && error.contains("is not there") {
return Some(format!(
"the app was removed outside Homebrew: bring it back with `brew reinstall --cask {names}`, or forget it with `brew uninstall --cask --force {names}`"
));
}
if error.contains("could not read Username") || error.contains("Repository not found") {
return Some(format!(
"the tap {names} can't be cloned from here: it may be private (sign in to git first) or no longer exist"
));
}
if error.contains("No formulae or casks found") || error.contains("No casks found") {
return Some(format!(
"Homebrew here doesn't know {names}: it may come from a tap that isn't set up on this machine, or have been renamed or removed"
));
}
None
}
fn has_refresh(&self) -> bool {
true
}
fn refresh(&self, bin: &Path) -> Result<(), String> {
capture(bin, ["update"])?.expect(&[0]).map(drop)
}
fn installed(&self, bin: &Path) -> Result<Vec<Installed>, String> {
let mut packages: Vec<Installed> = lines(bin, ["tap"])?
.into_iter()
.filter(|tap| !tap.starts_with("homebrew/"))
.map(|tap| Installed::new(tap).kind("tap"))
.collect();
packages.extend(
lines(bin, ["leaves", "--installed-on-request"])?
.into_iter()
.map(Installed::new),
);
packages.extend(
lines(bin, ["list", "--cask", "-1"])?
.into_iter()
.map(|cask| Installed::new(cask).kind("cask")),
);
Ok(packages)
}
fn install_steps(&self, bin: &Path, packages: &[Installed]) -> Vec<Step> {
let of = |kind: Option<&str>| -> Vec<Installed> {
packages
.iter()
.filter(|p| p.kind.as_deref() == kind)
.cloned()
.collect()
};
let mut steps: Vec<Step> = of(Some("tap"))
.iter()
.map(|tap| Step::install(["tap", &tap.name], slice::from_ref(tap)))
.collect();
steps.extend(Step::install_all(&["install"], &of(None)));
let casks = of(Some("cask"));
let names: Vec<&str> = casks.iter().map(|c| c.name.as_str()).collect();
let asking = password_casks(bin, &names);
let (password, quiet): (Vec<Installed>, Vec<Installed>) =
casks.into_iter().partition(|c| asking.contains(&c.name));
steps.extend(Step::install_all(&["install", "--cask"], &quiet));
steps.extend(
Step::install_all(&["install", "--cask"], &password)
.into_iter()
.map(Step::with_password),
);
steps
}
}
fn password_casks(bin: &Path, names: &[&str]) -> Vec<String> {
if names.is_empty() {
return Vec::new();
}
let mut args = vec!["info", "--json=v2"];
args.extend(names);
capture(bin, args)
.and_then(|out| out.expect(&[0]))
.and_then(|json| parse_password_casks(&json))
.unwrap_or_default()
}
fn parse_password_casks(json: &str) -> Result<Vec<String>, String> {
#[derive(Deserialize)]
struct Info {
#[serde(default)]
casks: Vec<Cask>,
}
#[derive(Deserialize)]
struct Cask {
token: String,
artifacts: Vec<serde_json::Map<String, serde_json::Value>>,
}
let info: Info =
serde_json::from_str(json).map_err(|e| format!("unexpected brew output: {e}"))?;
let as_root = |artifact: &serde_json::Map<String, serde_json::Value>| {
artifact.keys().any(|k| k == "pkg" || k == "installer")
|| artifact
.get("uninstall")
.and_then(|u| u.as_array())
.is_some_and(|directives| {
directives.iter().any(|d| {
["pkgutil", "launchctl", "kext", "script", "early_script"]
.iter()
.any(|k| d.get(k).is_some())
})
})
};
Ok(info
.casks
.into_iter()
.filter(|c| c.artifacts.iter().any(as_root))
.map(|c| c.token)
.collect())
}
fn greedy() -> Option<&'static str> {
crate::config::current().brew.greedy.then_some("--greedy")
}
#[derive(Deserialize)]
struct Report {
formulae: Vec<Item>,
casks: Vec<Item>,
}
#[derive(Deserialize)]
struct Item {
name: String,
installed_versions: Vec<String>,
current_version: String,
}
fn parse_outdated(json: &str) -> Result<Vec<Package>, String> {
let report: Report =
serde_json::from_str(json).map_err(|e| format!("unexpected brew output: {e}"))?;
Ok(report
.formulae
.into_iter()
.chain(report.casks)
.map(|item| Package {
name: item.name,
current: item.installed_versions.last().cloned().unwrap_or_default(),
latest: item.current_version,
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_formulae_and_casks() {
let json = r#"{
"formulae": [{"name": "git", "installed_versions": ["2.50.0", "2.51.0"], "current_version": "2.52.0", "pinned": false}],
"casks": [{"name": "firefox", "installed_versions": ["140.0"], "current_version": "141.0"}]
}"#;
let packages = parse_outdated(json).unwrap();
assert_eq!(
packages,
vec![
Package {
name: "git".into(),
current: "2.51.0".into(),
latest: "2.52.0".into()
},
Package {
name: "firefox".into(),
current: "140.0".into(),
latest: "141.0".into()
},
]
);
}
#[test]
fn explains_casks_whose_app_is_gone() {
let casks = [Package {
name: "caskhub".into(),
current: "0.9.0".into(),
latest: "0.9.3".into(),
}];
let error =
"Error: caskhub: It seems the App source '/Applications/CaskHub.app' is not there.";
assert!(
Brew.hint(&casks, error)
.unwrap()
.contains("brew reinstall --cask caskhub")
);
assert!(Brew.hint(&casks, "Error: network down").is_none());
assert!(
Brew.hint(&casks, "Error: No formulae or casks found for caskhub.")
.unwrap()
.contains("tap that isn't set up")
);
assert!(
Brew.hint(
&casks,
"fatal: could not read Username for 'https://github.com'"
)
.unwrap()
.contains("private")
);
}
#[test]
fn finds_casks_that_ask_for_a_password() {
let json = r#"{"formulae": [{"name": "git"}], "casks": [
{"token": "zoom", "artifacts": [{"uninstall": [{"launchctl": ["us.zoom.updater"], "pkgutil": "us.zoom.pkg"}]}, {"pkg": ["zoom.pkg"]}]},
{"token": "vorssaint", "artifacts": [{"uninstall": [{"quit": "com.vorssaint.utils"}]}, {"app": ["Vorssaint.app"]}, {"zap": [{"launchctl": ["x"]}]}]},
{"token": "karabiner", "artifacts": [{"uninstall": [{"script": {"executable": "uninstall.sh", "sudo": true}}]}]}
]}"#;
assert_eq!(parse_password_casks(json).unwrap(), ["zoom", "karabiner"]);
}
#[test]
fn parses_empty_report() {
assert!(
parse_outdated(r#"{"formulae": [], "casks": []}"#)
.unwrap()
.is_empty()
);
}
}