feat(brew): add cask support and brew-only configuration blocks

This commit is contained in:
Ray Andrew 2026-06-10 23:45:01 -07:00
parent 8d8495c89c
commit cc4684072d
Signed by: rayandrew
SSH key fingerprint: SHA256:iGurnBY6QgoHsQWxP3NgvMEA4F3GjTcszIJnLk2jinw
9 changed files with 358 additions and 129 deletions

View file

@ -556,16 +556,43 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
} }
// Package handling // Package handling
if !result.packages.is_empty() { let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty();
if !result.packages.is_empty() || has_brew_extras {
if !dry_run { if !dry_run {
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
} }
if let Some(manager) = doot_core::package::detect_package_manager() { if let Some(manager) = doot_core::package::detect_package_manager() {
let mut to_install = Vec::new(); let is_brew = manager.name() == "brew";
// Register taps first (no-op on non-brew managers).
if !result.brew_taps.is_empty() {
if dry_run {
println!("\n{}would tap:", dry_prefix);
for t in &result.brew_taps {
println!(" {t}");
}
} else {
let _ = manager.add_taps(&result.brew_taps);
}
}
// Resolve packages into formulae (install) and casks (install_casks).
// The `cask` channel only applies on brew; elsewhere a cask package
// falls back to its default/apt/... name like any other package.
let mut formulae = Vec::new();
let mut casks = Vec::new();
let mut already_installed = Vec::new(); let mut already_installed = Vec::new();
for pkg in &result.packages { for pkg in &result.packages {
if is_brew && pkg.cask.is_some() {
let name = pkg.cask.clone().unwrap();
match manager.is_installed(&name) {
Ok(true) => already_installed.push(name),
_ => casks.push(name),
}
continue;
}
let name = match manager.name() { let name = match manager.name() {
"brew" => pkg.brew.clone().or_else(|| pkg.default.clone()), "brew" => pkg.brew.clone().or_else(|| pkg.default.clone()),
"apt" => pkg.apt.clone().or_else(|| pkg.default.clone()), "apt" => pkg.apt.clone().or_else(|| pkg.default.clone()),
@ -577,29 +604,31 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
if let Some(name) = name { if let Some(name) = name {
match manager.is_installed(&name) { match manager.is_installed(&name) {
Ok(true) => already_installed.push(name), Ok(true) => already_installed.push(name),
_ => to_install.push(name), _ => formulae.push(name),
} }
} }
} }
if !already_installed.is_empty() { // Brew-only formulae from `brew:` blocks (ignored on other managers).
if dry_run { if is_brew {
println!("\n{}packages already installed:", dry_prefix); for name in &result.brew_formulae {
for pkg in &already_installed { match manager.is_installed(name) {
println!(" {}", pkg); Ok(true) => already_installed.push(name.clone()),
} _ => formulae.push(name.clone()),
} else {
tracing::debug!(
count = already_installed.len(),
"packages already installed"
);
for pkg in &already_installed {
tracing::debug!(package = %pkg, "already installed");
} }
} }
} }
if to_install.is_empty() { let total_to_install = formulae.len() + casks.len();
if !already_installed.is_empty() && !dry_run {
tracing::debug!(
count = already_installed.len(),
"packages already installed"
);
}
if total_to_install == 0 {
if !dry_run { if !dry_run {
println!( println!(
"\nall {} packages already installed", "\nall {} packages already installed",
@ -607,26 +636,38 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
); );
} }
} else if dry_run { } else if dry_run {
println!("\n{}would install packages:", dry_prefix); println!("\n{}would install:", dry_prefix);
for pkg in &to_install { for pkg in formulae.iter().chain(casks.iter()) {
println!(" {}", pkg); println!(" {pkg}");
} }
} else { } else {
println!("\ninstalling {} packages...", to_install.len()); println!("\ninstalling {total_to_install} packages...");
// Install is resilient: a failure for one package shouldn't abort // Resilient install: a failure for one package shouldn't abort apply.
// apply. Warn, then keep only the packages that actually landed so // Warn, then keep only the ones that actually landed so state records
// state records successes (and not failures). // successes (not failures).
if let Err(e) = manager.install(&to_install) { if !formulae.is_empty() {
eprintln!("warning: {e}"); if let Err(e) = manager.install(&formulae) {
eprintln!("warning: {e}");
}
formulae.retain(|pkg| manager.is_installed(pkg).unwrap_or(false));
} }
to_install.retain(|pkg| manager.is_installed(pkg).unwrap_or(false)); if !casks.is_empty() {
println!("installed {} packages", to_install.len()); if let Err(e) = manager.install_casks(&casks) {
eprintln!("warning: {e}");
}
casks.retain(|pkg| manager.is_installed(pkg).unwrap_or(false));
}
println!("installed {} packages", formulae.len() + casks.len());
} }
if !dry_run { if !dry_run {
let mut state = StateStore::new(&state_file); let mut state = StateStore::new(&state_file);
let manager_name = manager.name(); let manager_name = manager.name();
for pkg in to_install.iter().chain(already_installed.iter()) { for pkg in formulae
.iter()
.chain(casks.iter())
.chain(already_installed.iter())
{
state.record_package(pkg, manager_name); state.record_package(pkg, manager_name);
} }
state.save()?; state.save()?;
@ -645,11 +686,17 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let mgr_name = doot_core::package::detect_package_manager() let mgr_name = doot_core::package::detect_package_manager()
.map(|m| m.name().to_string()) .map(|m| m.name().to_string())
.unwrap_or_default(); .unwrap_or_default();
let configured_names: std::collections::HashSet<String> = result let mut configured_names: std::collections::HashSet<String> = result
.packages .packages
.iter() .iter()
.filter_map(|p| match mgr_name.as_str() { .filter_map(|p| match mgr_name.as_str() {
"brew" => p.brew.clone().or_else(|| p.default.clone()), // On brew a cask-only package has no brew/default name, so include
// the cask name too — otherwise it would look "removed" and be pruned.
"brew" => p
.cask
.clone()
.or_else(|| p.brew.clone())
.or_else(|| p.default.clone()),
"apt" => p.apt.clone().or_else(|| p.default.clone()), "apt" => p.apt.clone().or_else(|| p.default.clone()),
"pacman" => p.pacman.clone().or_else(|| p.default.clone()), "pacman" => p.pacman.clone().or_else(|| p.default.clone()),
"yay" => p.yay.clone().or_else(|| p.default.clone()), "yay" => p.yay.clone().or_else(|| p.default.clone()),
@ -658,6 +705,11 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
}) })
.collect(); .collect();
// Brew-only formulae from `brew:` blocks are configured packages too.
if mgr_name == "brew" {
configured_names.extend(result.brew_formulae.iter().cloned());
}
let state_for_prune = StateStore::new(&state_file); let state_for_prune = StateStore::new(&state_file);
let to_prune: Vec<(String, String)> = state_for_prune let to_prune: Vec<(String, String)> = state_for_prune
.get_all_packages() .get_all_packages()

View file

@ -118,19 +118,57 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
} }
} }
if !result.packages.is_empty() { let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty();
println!("\npackages ({}):", result.packages.len()); if (!result.packages.is_empty() || has_brew_extras)
if let Some(manager) = doot_core::package::detect_package_manager() { && let Some(manager) = doot_core::package::detect_package_manager()
for pkg in &result.packages { {
if let Some(ref name) = pkg.default { let is_brew = manager.name() == "brew";
let installed = manager.is_installed(name).unwrap_or(false);
let marker = if installed { // Resolve each package to the name/channel the active manager will use,
"\x1b[32m✓\x1b[0m" // mirroring `apply` (cask channel only applies on brew).
} else { let mut rows: Vec<(String, bool)> = Vec::new(); // (display, is_cask)
"\x1b[33m○\x1b[0m" for pkg in &result.packages {
}; let (name, is_cask) = if is_brew && pkg.cask.is_some() {
println!(" {} {}", marker, name); (pkg.cask.clone(), true)
} } else {
let n = match manager.name() {
"brew" => pkg.brew.clone().or_else(|| pkg.default.clone()),
"apt" => pkg.apt.clone().or_else(|| pkg.default.clone()),
"pacman" => pkg.pacman.clone().or_else(|| pkg.default.clone()),
"yay" => pkg.yay.clone().or_else(|| pkg.default.clone()),
"xbps" => pkg.xbps.clone().or_else(|| pkg.default.clone()),
_ => pkg.default.clone(),
};
(n, false)
};
if let Some(name) = name {
rows.push((name, is_cask));
}
}
// Brew-only formulae from `brew:` blocks (ignored on other managers).
if is_brew {
for name in &result.brew_formulae {
rows.push((name.clone(), false));
}
}
println!("\npackages ({}):", rows.len());
for (name, is_cask) in &rows {
let installed = manager.is_installed(name).unwrap_or(false);
let marker = if installed {
"\x1b[32m✓\x1b[0m"
} else {
"\x1b[33m○\x1b[0m"
};
let suffix = if *is_cask { " (cask)" } else { "" };
println!(" {} {}{}", marker, name, suffix);
}
// Taps (brew only).
if is_brew && !result.brew_taps.is_empty() {
println!("\ntaps ({}):", result.brew_taps.len());
for tap in &result.brew_taps {
println!(" {}", tap);
} }
} }
} }

View file

@ -66,6 +66,70 @@ impl Brew {
Ok(()) Ok(())
} }
/// Installs formulae (`cask == false`) or casks (`cask == true`), continuing
/// past individual failures. Bottles are fetched in parallel first (`brew fetch`
/// doesn't take the global install lock), then each is installed sequentially.
fn install_inner(&self, packages: &[String], cask: bool) -> Result<(), PackageError> {
if packages.is_empty() {
return Ok(());
}
let kind = if cask { "casks" } else { "formulae" };
if self.dry_run {
let flag = if cask { "--cask " } else { "" };
println!("[dry-run] brew install {flag}{}", packages.join(" "));
return Ok(());
}
// Phase 1: download concurrently (output captured to avoid interleaving;
// failures here are non-fatal — the install will surface them).
println!("downloading {} {kind}...", packages.len());
packages.par_iter().for_each(|pkg| {
let mut args = vec!["fetch"];
if cask {
args.push("--cask");
}
args.push(pkg);
let _ = Command::new("brew").args(&args).output();
});
// Phase 2: install each, streaming output and collecting failures.
let mut failed = Vec::new();
for pkg in packages {
println!("\n==> installing {pkg}");
let mut args = vec!["install"];
if cask {
args.push("--cask");
}
args.push(pkg);
let succeeded = Command::new("brew")
.args(&args)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !succeeded {
eprintln!("✗ failed to install {pkg}");
failed.push(pkg.clone());
}
}
self.installed_cache.invalidate();
if failed.is_empty() {
Ok(())
} else {
Err(PackageError::InstallFailed {
package: failed.join(", "),
message: format!(
"{} of {} {kind} failed: {}",
failed.len(),
packages.len(),
failed.join(", ")
),
})
}
}
} }
impl PackageManager for Brew { impl PackageManager for Brew {
@ -92,52 +156,34 @@ impl PackageManager for Brew {
/// is installed sequentially so one bad formula can't abort the rest — unlike /// is installed sequentially so one bad formula can't abort the rest — unlike
/// a single batched `brew install` which fails the whole set on any error. /// a single batched `brew install` which fails the whole set on any error.
fn install(&self, packages: &[String]) -> Result<(), PackageError> { fn install(&self, packages: &[String]) -> Result<(), PackageError> {
if packages.is_empty() { self.install_inner(packages, false)
return Ok(()); }
}
fn install_casks(&self, casks: &[String]) -> Result<(), PackageError> {
self.install_inner(casks, true)
}
/// Registers taps via `brew tap`. Idempotent; failures are warned, not fatal,
/// so a single bad tap can't block installs.
fn add_taps(&self, taps: &[String]) -> Result<(), PackageError> {
if self.dry_run { if self.dry_run {
println!("[dry-run] brew install {}", packages.join(" ")); for tap in taps {
println!("[dry-run] brew tap {tap}");
}
return Ok(()); return Ok(());
} }
for tap in taps {
// Phase 1: download all bottles concurrently (output captured to avoid println!("==> tapping {tap}");
// interleaving; failures here are non-fatal — the install will surface them). let ok = Command::new("brew")
println!("downloading {} bottles...", packages.len()); .args(["tap", tap])
packages.par_iter().for_each(|pkg| {
let _ = Command::new("brew").args(["fetch", pkg]).output();
});
// Phase 2: install each package, streaming output and collecting failures.
let mut failed = Vec::new();
for pkg in packages {
println!("\n==> installing {pkg}");
let succeeded = Command::new("brew")
.args(["install", pkg])
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false); .unwrap_or(false);
if !succeeded { if !ok {
eprintln!("✗ failed to install {pkg}"); eprintln!("✗ failed to tap {tap}");
failed.push(pkg.clone());
} }
} }
Ok(())
self.installed_cache.invalidate();
if failed.is_empty() {
Ok(())
} else {
Err(PackageError::InstallFailed {
package: failed.join(", "),
message: format!(
"{} of {} packages failed: {}",
failed.len(),
packages.len(),
failed.join(", ")
),
})
}
} }
fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> { fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> {

View file

@ -91,6 +91,18 @@ pub trait PackageManager: Send + Sync {
/// Upgrades installed packages. /// Upgrades installed packages.
fn upgrade(&self) -> Result<(), PackageError>; fn upgrade(&self) -> Result<(), PackageError>;
/// Registers package repositories (Homebrew taps). No-op for managers without
/// a tap concept.
fn add_taps(&self, _taps: &[String]) -> Result<(), PackageError> {
Ok(())
}
/// Installs GUI/cask packages (Homebrew casks). Defaults to a no-op for
/// managers without a separate cask channel.
fn install_casks(&self, _casks: &[String]) -> Result<(), PackageError> {
Ok(())
}
} }
/// Returns true if running in test mode (DOOT_TEST_MODE=1) /// Returns true if running in test mode (DOOT_TEST_MODE=1)

View file

@ -37,6 +37,7 @@ pub enum Statement {
Import(Import), Import(Import),
Dotfile(Box<Dotfile>), Dotfile(Box<Dotfile>),
Package(Box<Package>), Package(Box<Package>),
Brew(BrewConfig),
Secret(Secret), Secret(Secret),
Encrypted(EncryptedVars), Encrypted(EncryptedVars),
Hook(Hook), Hook(Hook),
@ -159,6 +160,8 @@ pub struct Dotfile {
pub struct Package { pub struct Package {
pub default: Option<Expr>, pub default: Option<Expr>,
pub brew: Option<PackageSpec>, pub brew: Option<PackageSpec>,
/// Homebrew cask (macOS GUI app); installed via `brew install --cask`.
pub cask: Option<PackageSpec>,
pub apt: Option<PackageSpec>, pub apt: Option<PackageSpec>,
pub pacman: Option<PackageSpec>, pub pacman: Option<PackageSpec>,
pub yay: Option<PackageSpec>, pub yay: Option<PackageSpec>,
@ -170,8 +173,16 @@ pub struct Package {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct PackageSpec { pub struct PackageSpec {
pub name: Expr, pub name: Expr,
pub cask: Option<bool>, }
pub tap: Option<String>,
/// Homebrew-specific configuration (`brew:` block): taps and brew-only formulae.
/// macOS-only; ignored on other platforms.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BrewConfig {
/// Repositories to register via `brew tap` (list expression).
pub taps: Option<Expr>,
/// Brew-only formulae to install (list expression).
pub formulae: Option<Expr>,
} }
/// Encrypted secret file declaration. /// Encrypted secret file declaration.

View file

@ -341,6 +341,8 @@ pub struct DotfileConfig {
pub struct PackageConfig { pub struct PackageConfig {
pub default: Option<String>, pub default: Option<String>,
pub brew: Option<String>, pub brew: Option<String>,
/// Homebrew cask name (macOS); installed via `brew install --cask`.
pub cask: Option<String>,
pub apt: Option<String>, pub apt: Option<String>,
pub pacman: Option<String>, pub pacman: Option<String>,
pub yay: Option<String>, pub yay: Option<String>,
@ -368,6 +370,10 @@ pub struct EvalResult {
pub dotfiles: Vec<DotfileConfig>, pub dotfiles: Vec<DotfileConfig>,
pub dotfile_patterns: Vec<DotfilesPattern>, pub dotfile_patterns: Vec<DotfilesPattern>,
pub packages: Vec<PackageConfig>, pub packages: Vec<PackageConfig>,
/// Homebrew taps to register (from `brew:` blocks), in declaration order.
pub brew_taps: Vec<String>,
/// Brew-only formulae to install (from `brew:` blocks).
pub brew_formulae: Vec<String>,
pub secrets: Vec<SecretConfig>, pub secrets: Vec<SecretConfig>,
pub hooks: Vec<HookConfig>, pub hooks: Vec<HookConfig>,
pub encrypted_vars: HashMap<String, String>, pub encrypted_vars: HashMap<String, String>,
@ -381,6 +387,8 @@ impl Default for EvalResult {
dotfiles: Vec::new(), dotfiles: Vec::new(),
dotfile_patterns: Vec::new(), dotfile_patterns: Vec::new(),
packages: Vec::new(), packages: Vec::new(),
brew_taps: Vec::new(),
brew_formulae: Vec::new(),
secrets: Vec::new(), secrets: Vec::new(),
hooks: Vec::new(), hooks: Vec::new(),
encrypted_vars: HashMap::new(), encrypted_vars: HashMap::new(),
@ -739,6 +747,11 @@ impl Evaluator {
} else { } else {
None None
}; };
let cask = if let Some(ref s) = pkg.cask {
Some(self.eval_to_string(&s.name).await?)
} else {
None
};
let apt = if let Some(ref s) = pkg.apt { let apt = if let Some(ref s) = pkg.apt {
Some(self.eval_to_string(&s.name).await?) Some(self.eval_to_string(&s.name).await?)
} else { } else {
@ -763,6 +776,7 @@ impl Evaluator {
self.result.packages.push(PackageConfig { self.result.packages.push(PackageConfig {
default, default,
brew, brew,
cask,
apt, apt,
pacman, pacman,
yay, yay,
@ -771,6 +785,19 @@ impl Evaluator {
Ok(None) Ok(None)
} }
Statement::Brew(cfg) => {
tracing::trace!("eval brew block");
if let Some(ref taps) = cfg.taps {
let taps = self.eval_to_string_list(taps).await?;
self.result.brew_taps.extend(taps);
}
if let Some(ref formulae) = cfg.formulae {
let formulae = self.eval_to_string_list(formulae).await?;
self.result.brew_formulae.extend(formulae);
}
Ok(None)
}
Statement::Secret(secret) => { Statement::Secret(secret) => {
let source = self.eval_to_path(&secret.source).await?; let source = self.eval_to_path(&secret.source).await?;
let target = self.eval_to_path(&secret.target).await?; let target = self.eval_to_path(&secret.target).await?;
@ -1440,6 +1467,16 @@ impl Evaluator {
Ok(val.to_string_repr()) Ok(val.to_string_repr())
} }
/// Evaluates an expression to a list of strings. A list yields each element's
/// string form; a single (non-list) value yields a one-element list.
#[tracing::instrument(level = "trace", skip_all)]
async fn eval_to_string_list(&mut self, expr: &Expr) -> Result<Vec<String>, EvalError> {
match self.eval_expr(expr).await? {
Value::List(items) => Ok(items.iter().map(|v| v.to_string_repr()).collect()),
other => Ok(vec![other.to_string_repr()]),
}
}
pub fn env(&self) -> &Env { pub fn env(&self) -> &Env {
&self.env &self.env
} }

View file

@ -33,6 +33,7 @@ pub enum Token {
As, As,
Dotfile, Dotfile,
Package, Package,
Brew,
Secret, Secret,
Encrypted, Encrypted,
Hook, Hook,
@ -115,6 +116,7 @@ impl fmt::Display for Token {
Token::As => write!(f, "as"), Token::As => write!(f, "as"),
Token::Dotfile => write!(f, "dotfile"), Token::Dotfile => write!(f, "dotfile"),
Token::Package => write!(f, "package"), Token::Package => write!(f, "package"),
Token::Brew => write!(f, "brew"),
Token::Secret => write!(f, "secret"), Token::Secret => write!(f, "secret"),
Token::Encrypted => write!(f, "encrypted"), Token::Encrypted => write!(f, "encrypted"),
Token::Hook => write!(f, "hook"), Token::Hook => write!(f, "hook"),
@ -254,6 +256,7 @@ impl Lexer {
"as" => Token::As, "as" => Token::As,
"dotfile" => Token::Dotfile, "dotfile" => Token::Dotfile,
"package" => Token::Package, "package" => Token::Package,
"brew" => Token::Brew,
"secret" => Token::Secret, "secret" => Token::Secret,
"encrypted" => Token::Encrypted, "encrypted" => Token::Encrypted,
"hook" => Token::Hook, "hook" => Token::Hook,

View file

@ -75,28 +75,21 @@ impl MacroExpander {
default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)), default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)),
brew: pkg.brew.as_ref().map(|s| PackageSpec { brew: pkg.brew.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs), name: self.substitute_expr(&s.name, subs),
cask: s.cask, }),
tap: s.tap.clone(), cask: pkg.cask.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs),
}), }),
apt: pkg.apt.as_ref().map(|s| PackageSpec { apt: pkg.apt.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs), name: self.substitute_expr(&s.name, subs),
cask: s.cask,
tap: s.tap.clone(),
}), }),
pacman: pkg.pacman.as_ref().map(|s| PackageSpec { pacman: pkg.pacman.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs), name: self.substitute_expr(&s.name, subs),
cask: s.cask,
tap: s.tap.clone(),
}), }),
yay: pkg.yay.as_ref().map(|s| PackageSpec { yay: pkg.yay.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs), name: self.substitute_expr(&s.name, subs),
cask: s.cask,
tap: s.tap.clone(),
}), }),
xbps: pkg.xbps.as_ref().map(|s| PackageSpec { xbps: pkg.xbps.as_ref().map(|s| PackageSpec {
name: self.substitute_expr(&s.name, subs), name: self.substitute_expr(&s.name, subs),
cask: s.cask,
tap: s.tap.clone(),
}), }),
when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)), when: pkg.when.as_ref().map(|e| self.substitute_expr(e, subs)),
})), })),

View file

@ -44,6 +44,7 @@ impl Parser {
let import = Self::import_parser().map(Statement::Import); let import = Self::import_parser().map(Statement::Import);
let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d))); let dotfile = Self::dotfile_parser().map(|d| Statement::Dotfile(Box::new(d)));
let package = Self::package_parser().map(|p| Statement::Package(Box::new(p))); let package = Self::package_parser().map(|p| Statement::Package(Box::new(p)));
let brew = Self::brew_parser().map(Statement::Brew);
let secret = Self::secret_parser().map(Statement::Secret); let secret = Self::secret_parser().map(Statement::Secret);
let encrypted = Self::encrypted_parser().map(Statement::Encrypted); let encrypted = Self::encrypted_parser().map(Statement::Encrypted);
let hook = Self::hook_parser().map(Statement::Hook); let hook = Self::hook_parser().map(Statement::Hook);
@ -66,6 +67,7 @@ impl Parser {
import, import,
dotfile, dotfile,
package, package,
brew,
secret, secret,
encrypted, encrypted,
hook, hook,
@ -221,7 +223,11 @@ impl Parser {
} }
fn field_name_parser() -> impl chumsky::Parser<Token, String, Error = Simple<Token>> + Clone { fn field_name_parser() -> impl chumsky::Parser<Token, String, Error = Simple<Token>> + Clone {
Self::ident_parser().or(just(Token::When).to("when".to_string())) Self::ident_parser()
.or(just(Token::When).to("when".to_string()))
// `brew` is a keyword (for the `brew:` block) but is also a valid
// package-manager field name inside `package:` blocks.
.or(just(Token::Brew).to("brew".to_string()))
} }
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> { fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
@ -311,6 +317,7 @@ impl Parser {
.map(|name| Package { .map(|name| Package {
default: Some(name), default: Some(name),
brew: None, brew: None,
cask: None,
apt: None, apt: None,
pacman: None, pacman: None,
yay: None, yay: None,
@ -338,6 +345,7 @@ impl Parser {
let mut pkg = Package { let mut pkg = Package {
default: None, default: None,
brew: None, brew: None,
cask: None,
apt: None, apt: None,
pacman: None, pacman: None,
yay: None, yay: None,
@ -347,41 +355,12 @@ impl Parser {
for (name, value) in fields { for (name, value) in fields {
match name.as_str() { match name.as_str() {
"default" => pkg.default = Some(value), "default" => pkg.default = Some(value),
"brew" => { "brew" => pkg.brew = Some(PackageSpec { name: value }),
pkg.brew = Some(PackageSpec { "cask" => pkg.cask = Some(PackageSpec { name: value }),
name: value, "apt" => pkg.apt = Some(PackageSpec { name: value }),
cask: None, "pacman" => pkg.pacman = Some(PackageSpec { name: value }),
tap: None, "yay" => pkg.yay = Some(PackageSpec { name: value }),
}) "xbps" => pkg.xbps = Some(PackageSpec { name: value }),
}
"apt" => {
pkg.apt = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"pacman" => {
pkg.pacman = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"yay" => {
pkg.yay = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"xbps" => {
pkg.xbps = Some(PackageSpec {
name: value,
cask: None,
tap: None,
})
}
"when" => pkg.when = Some(value), "when" => pkg.when = Some(value),
_ => {} _ => {}
} }
@ -392,6 +371,37 @@ impl Parser {
inline.or(block) inline.or(block)
} }
/// Parses a `brew:` block holding brew-only configuration (`taps`, `formulae`).
fn brew_parser() -> impl chumsky::Parser<Token, BrewConfig, Error = Simple<Token>> {
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser());
just(Token::Brew)
.ignore_then(just(Token::Colon))
.ignore_then(just(Token::Newline).repeated())
.ignore_then(Self::indent_parser())
.ignore_then(
field
.padded_by(Self::indent_parser())
.padded_by(just(Token::Newline).repeated())
.repeated()
.at_least(1),
)
.then_ignore(just(Token::Dedent).or_not())
.map(|fields| {
let mut cfg = BrewConfig::default();
for (name, value) in fields {
match name.as_str() {
"taps" => cfg.taps = Some(value),
"formulae" => cfg.formulae = Some(value),
_ => {}
}
}
cfg
})
}
fn secret_parser() -> impl chumsky::Parser<Token, Secret, Error = Simple<Token>> { fn secret_parser() -> impl chumsky::Parser<Token, Secret, Error = Simple<Token>> {
let field = Self::field_name_parser() let field = Self::field_name_parser()
.then_ignore(just(Token::Eq)) .then_ignore(just(Token::Eq))
@ -1057,6 +1067,33 @@ mod tests {
} }
} }
#[test]
fn test_brew_block_and_cask_field() {
// `brew:` block with taps + formulae lists.
let program = parse_source(
"brew:\n taps = [\"homebrew/cask-fonts\"]\n formulae = [\"mas\", \"trash\"]\n",
);
assert_eq!(program.statements.len(), 1);
match &program.statements[0].node {
Statement::Brew(cfg) => {
assert!(cfg.taps.is_some());
assert!(cfg.formulae.is_some());
}
other => panic!("expected Brew statement, got {other:?}"),
}
// `cask` is a package field; `brew` still works as a field name despite
// being a keyword now.
let program = parse_source("package:\n brew = \"ripgrep\"\n cask = \"firefox\"\n");
match &program.statements[0].node {
Statement::Package(pkg) => {
assert!(pkg.brew.is_some());
assert!(pkg.cask.is_some());
}
other => panic!("expected Package statement, got {other:?}"),
}
}
#[test] #[test]
fn test_encrypted_file_entries() { fn test_encrypted_file_entries() {
let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n"; let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n";