diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index e84405f..776072f 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -556,16 +556,43 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } // 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 { run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?; } 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(); 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() { "brew" => pkg.brew.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, dry_run: bool, prune: bool) -> anyhow:: if let Some(name) = name { match manager.is_installed(&name) { Ok(true) => already_installed.push(name), - _ => to_install.push(name), + _ => formulae.push(name), } } } - if !already_installed.is_empty() { - if dry_run { - println!("\n{}packages already installed:", dry_prefix); - for pkg in &already_installed { - println!(" {}", pkg); - } - } else { - tracing::debug!( - count = already_installed.len(), - "packages already installed" - ); - for pkg in &already_installed { - tracing::debug!(package = %pkg, "already installed"); + // Brew-only formulae from `brew:` blocks (ignored on other managers). + if is_brew { + for name in &result.brew_formulae { + match manager.is_installed(name) { + Ok(true) => already_installed.push(name.clone()), + _ => formulae.push(name.clone()), } } } - 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 { println!( "\nall {} packages already installed", @@ -607,26 +636,38 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: ); } } else if dry_run { - println!("\n{}would install packages:", dry_prefix); - for pkg in &to_install { - println!(" {}", pkg); + println!("\n{}would install:", dry_prefix); + for pkg in formulae.iter().chain(casks.iter()) { + println!(" {pkg}"); } } else { - println!("\ninstalling {} packages...", to_install.len()); - // Install is resilient: a failure for one package shouldn't abort - // apply. Warn, then keep only the packages that actually landed so - // state records successes (and not failures). - if let Err(e) = manager.install(&to_install) { - eprintln!("warning: {e}"); + println!("\ninstalling {total_to_install} packages..."); + // Resilient install: a failure for one package shouldn't abort apply. + // Warn, then keep only the ones that actually landed so state records + // successes (not failures). + if !formulae.is_empty() { + 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)); - println!("installed {} packages", to_install.len()); + if !casks.is_empty() { + 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 { let mut state = StateStore::new(&state_file); 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.save()?; @@ -645,11 +686,17 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: let mgr_name = doot_core::package::detect_package_manager() .map(|m| m.name().to_string()) .unwrap_or_default(); - let configured_names: std::collections::HashSet = result + let mut configured_names: std::collections::HashSet = result .packages .iter() .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()), "pacman" => p.pacman.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, dry_run: bool, prune: bool) -> anyhow:: }) .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 to_prune: Vec<(String, String)> = state_for_prune .get_all_packages() diff --git a/crates/doot-cli/src/commands/status.rs b/crates/doot-cli/src/commands/status.rs index 8084f8c..6449ef6 100644 --- a/crates/doot-cli/src/commands/status.rs +++ b/crates/doot-cli/src/commands/status.rs @@ -118,19 +118,57 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { } } - if !result.packages.is_empty() { - println!("\npackages ({}):", result.packages.len()); - if let Some(manager) = doot_core::package::detect_package_manager() { - for pkg in &result.packages { - if let Some(ref name) = pkg.default { - let installed = manager.is_installed(name).unwrap_or(false); - let marker = if installed { - "\x1b[32m✓\x1b[0m" - } else { - "\x1b[33m○\x1b[0m" - }; - println!(" {} {}", marker, name); - } + let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty(); + if (!result.packages.is_empty() || has_brew_extras) + && let Some(manager) = doot_core::package::detect_package_manager() + { + let is_brew = manager.name() == "brew"; + + // Resolve each package to the name/channel the active manager will use, + // mirroring `apply` (cask channel only applies on brew). + let mut rows: Vec<(String, bool)> = Vec::new(); // (display, is_cask) + for pkg in &result.packages { + let (name, is_cask) = if is_brew && pkg.cask.is_some() { + (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); } } } diff --git a/crates/doot-core/src/package/brew.rs b/crates/doot-core/src/package/brew.rs index 2ba5906..d92225f 100644 --- a/crates/doot-core/src/package/brew.rs +++ b/crates/doot-core/src/package/brew.rs @@ -66,6 +66,70 @@ impl Brew { 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 { @@ -92,52 +156,34 @@ impl PackageManager for Brew { /// 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. fn install(&self, packages: &[String]) -> Result<(), PackageError> { - if packages.is_empty() { - return Ok(()); - } + self.install_inner(packages, false) + } + 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 { - println!("[dry-run] brew install {}", packages.join(" ")); + for tap in taps { + println!("[dry-run] brew tap {tap}"); + } return Ok(()); } - - // Phase 1: download all bottles concurrently (output captured to avoid - // interleaving; failures here are non-fatal — the install will surface them). - println!("downloading {} bottles...", packages.len()); - 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]) + for tap in taps { + println!("==> tapping {tap}"); + let ok = Command::new("brew") + .args(["tap", tap]) .status() .map(|s| s.success()) .unwrap_or(false); - if !succeeded { - eprintln!("✗ failed to install {pkg}"); - failed.push(pkg.clone()); + if !ok { + eprintln!("✗ failed to tap {tap}"); } } - - 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(", ") - ), - }) - } + Ok(()) } fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> { diff --git a/crates/doot-core/src/package/mod.rs b/crates/doot-core/src/package/mod.rs index 71717cc..203c9ca 100644 --- a/crates/doot-core/src/package/mod.rs +++ b/crates/doot-core/src/package/mod.rs @@ -91,6 +91,18 @@ pub trait PackageManager: Send + Sync { /// Upgrades installed packages. 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) diff --git a/crates/doot-lang/src/ast.rs b/crates/doot-lang/src/ast.rs index 8dc2091..415962b 100644 --- a/crates/doot-lang/src/ast.rs +++ b/crates/doot-lang/src/ast.rs @@ -37,6 +37,7 @@ pub enum Statement { Import(Import), Dotfile(Box), Package(Box), + Brew(BrewConfig), Secret(Secret), Encrypted(EncryptedVars), Hook(Hook), @@ -159,6 +160,8 @@ pub struct Dotfile { pub struct Package { pub default: Option, pub brew: Option, + /// Homebrew cask (macOS GUI app); installed via `brew install --cask`. + pub cask: Option, pub apt: Option, pub pacman: Option, pub yay: Option, @@ -170,8 +173,16 @@ pub struct Package { #[derive(Clone, Debug, PartialEq)] pub struct PackageSpec { pub name: Expr, - pub cask: Option, - pub tap: Option, +} + +/// 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, + /// Brew-only formulae to install (list expression). + pub formulae: Option, } /// Encrypted secret file declaration. diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs index f77f3ac..ef362ce 100644 --- a/crates/doot-lang/src/evaluator.rs +++ b/crates/doot-lang/src/evaluator.rs @@ -341,6 +341,8 @@ pub struct DotfileConfig { pub struct PackageConfig { pub default: Option, pub brew: Option, + /// Homebrew cask name (macOS); installed via `brew install --cask`. + pub cask: Option, pub apt: Option, pub pacman: Option, pub yay: Option, @@ -368,6 +370,10 @@ pub struct EvalResult { pub dotfiles: Vec, pub dotfile_patterns: Vec, pub packages: Vec, + /// Homebrew taps to register (from `brew:` blocks), in declaration order. + pub brew_taps: Vec, + /// Brew-only formulae to install (from `brew:` blocks). + pub brew_formulae: Vec, pub secrets: Vec, pub hooks: Vec, pub encrypted_vars: HashMap, @@ -381,6 +387,8 @@ impl Default for EvalResult { dotfiles: Vec::new(), dotfile_patterns: Vec::new(), packages: Vec::new(), + brew_taps: Vec::new(), + brew_formulae: Vec::new(), secrets: Vec::new(), hooks: Vec::new(), encrypted_vars: HashMap::new(), @@ -739,6 +747,11 @@ impl Evaluator { } else { 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 { Some(self.eval_to_string(&s.name).await?) } else { @@ -763,6 +776,7 @@ impl Evaluator { self.result.packages.push(PackageConfig { default, brew, + cask, apt, pacman, yay, @@ -771,6 +785,19 @@ impl Evaluator { 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) => { let source = self.eval_to_path(&secret.source).await?; let target = self.eval_to_path(&secret.target).await?; @@ -1440,6 +1467,16 @@ impl Evaluator { 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, 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 { &self.env } diff --git a/crates/doot-lang/src/lexer.rs b/crates/doot-lang/src/lexer.rs index 67aa54d..8f6408b 100644 --- a/crates/doot-lang/src/lexer.rs +++ b/crates/doot-lang/src/lexer.rs @@ -33,6 +33,7 @@ pub enum Token { As, Dotfile, Package, + Brew, Secret, Encrypted, Hook, @@ -115,6 +116,7 @@ impl fmt::Display for Token { Token::As => write!(f, "as"), Token::Dotfile => write!(f, "dotfile"), Token::Package => write!(f, "package"), + Token::Brew => write!(f, "brew"), Token::Secret => write!(f, "secret"), Token::Encrypted => write!(f, "encrypted"), Token::Hook => write!(f, "hook"), @@ -254,6 +256,7 @@ impl Lexer { "as" => Token::As, "dotfile" => Token::Dotfile, "package" => Token::Package, + "brew" => Token::Brew, "secret" => Token::Secret, "encrypted" => Token::Encrypted, "hook" => Token::Hook, diff --git a/crates/doot-lang/src/macros.rs b/crates/doot-lang/src/macros.rs index aff4cc6..b49b81f 100644 --- a/crates/doot-lang/src/macros.rs +++ b/crates/doot-lang/src/macros.rs @@ -75,28 +75,21 @@ impl MacroExpander { default: pkg.default.as_ref().map(|e| self.substitute_expr(e, subs)), brew: pkg.brew.as_ref().map(|s| PackageSpec { 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 { name: self.substitute_expr(&s.name, subs), - cask: s.cask, - tap: s.tap.clone(), }), pacman: pkg.pacman.as_ref().map(|s| PackageSpec { name: self.substitute_expr(&s.name, subs), - cask: s.cask, - tap: s.tap.clone(), }), yay: pkg.yay.as_ref().map(|s| PackageSpec { name: self.substitute_expr(&s.name, subs), - cask: s.cask, - tap: s.tap.clone(), }), xbps: pkg.xbps.as_ref().map(|s| PackageSpec { 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)), })), diff --git a/crates/doot-lang/src/parser.rs b/crates/doot-lang/src/parser.rs index 67ceec3..4a23706 100644 --- a/crates/doot-lang/src/parser.rs +++ b/crates/doot-lang/src/parser.rs @@ -44,6 +44,7 @@ impl Parser { let import = Self::import_parser().map(Statement::Import); 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 brew = Self::brew_parser().map(Statement::Brew); let secret = Self::secret_parser().map(Statement::Secret); let encrypted = Self::encrypted_parser().map(Statement::Encrypted); let hook = Self::hook_parser().map(Statement::Hook); @@ -66,6 +67,7 @@ impl Parser { import, dotfile, package, + brew, secret, encrypted, hook, @@ -221,7 +223,11 @@ impl Parser { } fn field_name_parser() -> impl chumsky::Parser> + 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> { @@ -311,6 +317,7 @@ impl Parser { .map(|name| Package { default: Some(name), brew: None, + cask: None, apt: None, pacman: None, yay: None, @@ -338,6 +345,7 @@ impl Parser { let mut pkg = Package { default: None, brew: None, + cask: None, apt: None, pacman: None, yay: None, @@ -347,41 +355,12 @@ impl Parser { for (name, value) in fields { match name.as_str() { "default" => pkg.default = Some(value), - "brew" => { - pkg.brew = Some(PackageSpec { - name: value, - cask: None, - tap: None, - }) - } - "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, - }) - } + "brew" => pkg.brew = Some(PackageSpec { name: value }), + "cask" => pkg.cask = Some(PackageSpec { name: value }), + "apt" => pkg.apt = Some(PackageSpec { name: value }), + "pacman" => pkg.pacman = Some(PackageSpec { name: value }), + "yay" => pkg.yay = Some(PackageSpec { name: value }), + "xbps" => pkg.xbps = Some(PackageSpec { name: value }), "when" => pkg.when = Some(value), _ => {} } @@ -392,6 +371,37 @@ impl Parser { inline.or(block) } + /// Parses a `brew:` block holding brew-only configuration (`taps`, `formulae`). + fn brew_parser() -> impl chumsky::Parser> { + 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> { let field = Self::field_name_parser() .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] fn test_encrypted_file_entries() { let src = "encrypted:\n SSH_KEY = file(\"secrets/id_rsa.age\")\n CONFIG = file(\"secrets/app.conf.age\")\n";