Compare commits
No commits in common. "cc4684072dd5f2027c76fc66e512bc71f10623c0" and "0eb4d383927412c3692de0fae0204ba4e595a17f" have entirely different histories.
cc4684072d
...
0eb4d38392
10 changed files with 132 additions and 469 deletions
|
|
@ -32,12 +32,6 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
// Get environment variables to expose to hook scripts
|
// Get environment variables to expose to hook scripts
|
||||||
let hook_env = evaluator.get_hook_env();
|
let hook_env = evaluator.get_hook_env();
|
||||||
|
|
||||||
// Warn about likely-mistaken directory targets before expanding globs, while
|
|
||||||
// result.dotfiles still holds only the explicit (non-glob) blocks.
|
|
||||||
for warning in directory_target_collisions(&result.dotfiles, &result.dotfile_patterns) {
|
|
||||||
eprintln!("warning: {warning}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand glob patterns from dotfiles: blocks
|
// Expand glob patterns from dotfiles: blocks
|
||||||
let glob_count =
|
let glob_count =
|
||||||
expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir);
|
expand_dotfile_patterns(&mut result.dotfiles, &result.dotfile_patterns, &source_dir);
|
||||||
|
|
@ -556,43 +550,16 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package handling
|
// Package handling
|
||||||
let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty();
|
if !result.packages.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 is_brew = manager.name() == "brew";
|
let mut to_install = Vec::new();
|
||||||
|
|
||||||
// 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()),
|
||||||
|
|
@ -604,31 +571,29 @@ 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),
|
||||||
_ => formulae.push(name),
|
_ => to_install.push(name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brew-only formulae from `brew:` blocks (ignored on other managers).
|
if !already_installed.is_empty() {
|
||||||
if is_brew {
|
if dry_run {
|
||||||
for name in &result.brew_formulae {
|
println!("\n{}packages already installed:", dry_prefix);
|
||||||
match manager.is_installed(name) {
|
for pkg in &already_installed {
|
||||||
Ok(true) => already_installed.push(name.clone()),
|
println!(" {}", pkg);
|
||||||
_ => 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_to_install = formulae.len() + casks.len();
|
if to_install.is_empty() {
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -636,38 +601,26 @@ 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:", dry_prefix);
|
println!("\n{}would install packages:", dry_prefix);
|
||||||
for pkg in formulae.iter().chain(casks.iter()) {
|
for pkg in &to_install {
|
||||||
println!(" {pkg}");
|
println!(" {}", pkg);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("\ninstalling {total_to_install} packages...");
|
println!("\ninstalling {} packages...", to_install.len());
|
||||||
// Resilient install: a failure for one package shouldn't abort apply.
|
// Install is resilient: a failure for one package shouldn't abort
|
||||||
// Warn, then keep only the ones that actually landed so state records
|
// apply. Warn, then keep only the packages that actually landed so
|
||||||
// successes (not failures).
|
// state records successes (and not failures).
|
||||||
if !formulae.is_empty() {
|
if let Err(e) = manager.install(&to_install) {
|
||||||
if let Err(e) = manager.install(&formulae) {
|
eprintln!("warning: {e}");
|
||||||
eprintln!("warning: {e}");
|
|
||||||
}
|
|
||||||
formulae.retain(|pkg| manager.is_installed(pkg).unwrap_or(false));
|
|
||||||
}
|
}
|
||||||
if !casks.is_empty() {
|
to_install.retain(|pkg| manager.is_installed(pkg).unwrap_or(false));
|
||||||
if let Err(e) = manager.install_casks(&casks) {
|
println!("installed {} packages", to_install.len());
|
||||||
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 formulae
|
for pkg in to_install.iter().chain(already_installed.iter()) {
|
||||||
.iter()
|
|
||||||
.chain(casks.iter())
|
|
||||||
.chain(already_installed.iter())
|
|
||||||
{
|
|
||||||
state.record_package(pkg, manager_name);
|
state.record_package(pkg, manager_name);
|
||||||
}
|
}
|
||||||
state.save()?;
|
state.save()?;
|
||||||
|
|
@ -686,17 +639,11 @@ 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 mut configured_names: std::collections::HashSet<String> = result
|
let 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() {
|
||||||
// On brew a cask-only package has no brew/default name, so include
|
"brew" => p.brew.clone().or_else(|| p.default.clone()),
|
||||||
// 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()),
|
||||||
|
|
@ -705,11 +652,6 @@ 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()
|
||||||
|
|
@ -1001,40 +943,6 @@ pub(crate) fn template_outdated(
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns warnings for explicit dotfile blocks whose target is exactly a glob
|
|
||||||
/// pattern's directory target. For a directory target, doot appends the source
|
|
||||||
/// filename at deploy time, so the explicit entry lands on the *same path* the
|
|
||||||
/// glob already produces — they collide instead of the explicit block overriding
|
|
||||||
/// the glob. Targeting the specific file (`… / "<name>"`) is what makes the
|
|
||||||
/// override fire. (`validate_dotfile_targets` can't catch this: the raw target
|
|
||||||
/// fields differ — `…/bin` vs `…/bin/wb` — only the deployed paths coincide.)
|
|
||||||
fn directory_target_collisions(
|
|
||||||
dotfiles: &[DotfileConfig],
|
|
||||||
patterns: &[DotfilesPattern],
|
|
||||||
) -> Vec<String> {
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
for df in dotfiles {
|
|
||||||
for pat in patterns {
|
|
||||||
if df.target == pat.target_base {
|
|
||||||
let file = df
|
|
||||||
.source
|
|
||||||
.file_name()
|
|
||||||
.map(|f| f.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_else(|| "<name>".to_string());
|
|
||||||
warnings.push(format!(
|
|
||||||
"dotfile '{}' targets the directory '{}', which is also a glob target. \
|
|
||||||
It will collide with the glob instead of overriding it. \
|
|
||||||
Did you mean: target = ... / \"{}\"",
|
|
||||||
df.source.display(),
|
|
||||||
df.target.display(),
|
|
||||||
file,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries.
|
/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries.
|
||||||
/// Returns the number of entries added.
|
/// Returns the number of entries added.
|
||||||
fn expand_dotfile_patterns(
|
fn expand_dotfile_patterns(
|
||||||
|
|
@ -1151,56 +1059,3 @@ fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
|
||||||
dotfiles.remove(idx);
|
dotfiles.remove(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use doot_lang::evaluator::{DeployMode, DotfilesSource};
|
|
||||||
|
|
||||||
fn explicit(source: &str, target: &str) -> DotfileConfig {
|
|
||||||
DotfileConfig {
|
|
||||||
source: PathBuf::from(source),
|
|
||||||
target: PathBuf::from(target),
|
|
||||||
template: false,
|
|
||||||
permissions: vec![],
|
|
||||||
owner: None,
|
|
||||||
deploy: DeployMode::default(),
|
|
||||||
link_patterns: vec![],
|
|
||||||
copy_patterns: vec![],
|
|
||||||
exclude_paths: vec![],
|
|
||||||
exclude_sources: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glob_pattern(pattern: &str, target_base: &str) -> DotfilesPattern {
|
|
||||||
DotfilesPattern {
|
|
||||||
source: DotfilesSource::Pattern(pattern.to_string()),
|
|
||||||
target_base: PathBuf::from(target_base),
|
|
||||||
template: false,
|
|
||||||
permissions: vec![],
|
|
||||||
owner: None,
|
|
||||||
deploy: DeployMode::default(),
|
|
||||||
link_patterns: vec![],
|
|
||||||
copy_patterns: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn warns_when_explicit_target_is_glob_directory() {
|
|
||||||
// explicit `bin/wb` -> ~/.local/bin (the directory) collides with `bin/*`.
|
|
||||||
let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin")];
|
|
||||||
let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")];
|
|
||||||
let warnings = directory_target_collisions(&dotfiles, &patterns);
|
|
||||||
assert_eq!(warnings.len(), 1);
|
|
||||||
assert!(warnings[0].contains("wb"));
|
|
||||||
assert!(warnings[0].contains("collide"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_warning_for_specific_file_target() {
|
|
||||||
// explicit `bin/wb` -> ~/.local/bin/wb (a file) overrides correctly.
|
|
||||||
let dotfiles = vec![explicit("bin/wb", "/home/u/.local/bin/wb")];
|
|
||||||
let patterns = vec![glob_pattern("bin/*", "/home/u/.local/bin")];
|
|
||||||
assert!(directory_target_collisions(&dotfiles, &patterns).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -118,57 +118,19 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_brew_extras = !result.brew_taps.is_empty() || !result.brew_formulae.is_empty();
|
if !result.packages.is_empty() {
|
||||||
if (!result.packages.is_empty() || has_brew_extras)
|
println!("\npackages ({}):", result.packages.len());
|
||||||
&& let Some(manager) = doot_core::package::detect_package_manager()
|
if let Some(manager) = doot_core::package::detect_package_manager() {
|
||||||
{
|
for pkg in &result.packages {
|
||||||
let is_brew = manager.name() == "brew";
|
if let Some(ref name) = pkg.default {
|
||||||
|
let installed = manager.is_installed(name).unwrap_or(false);
|
||||||
// Resolve each package to the name/channel the active manager will use,
|
let marker = if installed {
|
||||||
// mirroring `apply` (cask channel only applies on brew).
|
"\x1b[32m✓\x1b[0m"
|
||||||
let mut rows: Vec<(String, bool)> = Vec::new(); // (display, is_cask)
|
} else {
|
||||||
for pkg in &result.packages {
|
"\x1b[33m○\x1b[0m"
|
||||||
let (name, is_cask) = if is_brew && pkg.cask.is_some() {
|
};
|
||||||
(pkg.cask.clone(), true)
|
println!(" {} {}", marker, name);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,70 +66,6 @@ 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 {
|
||||||
|
|
@ -156,34 +92,52 @@ 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> {
|
||||||
self.install_inner(packages, false)
|
if packages.is_empty() {
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
for tap in taps {
|
|
||||||
println!("[dry-run] brew tap {tap}");
|
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for tap in taps {
|
|
||||||
println!("==> tapping {tap}");
|
if self.dry_run {
|
||||||
let ok = Command::new("brew")
|
println!("[dry-run] brew install {}", packages.join(" "));
|
||||||
.args(["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])
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !ok {
|
if !succeeded {
|
||||||
eprintln!("✗ failed to tap {tap}");
|
eprintln!("✗ failed to install {pkg}");
|
||||||
|
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> {
|
||||||
|
|
|
||||||
|
|
@ -91,18 +91,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ 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),
|
||||||
|
|
@ -160,8 +159,6 @@ 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>,
|
||||||
|
|
@ -173,16 +170,8 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -341,8 +341,6 @@ 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>,
|
||||||
|
|
@ -370,10 +368,6 @@ 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>,
|
||||||
|
|
@ -387,8 +381,6 @@ 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(),
|
||||||
|
|
@ -747,11 +739,6 @@ 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 {
|
||||||
|
|
@ -776,7 +763,6 @@ impl Evaluator {
|
||||||
self.result.packages.push(PackageConfig {
|
self.result.packages.push(PackageConfig {
|
||||||
default,
|
default,
|
||||||
brew,
|
brew,
|
||||||
cask,
|
|
||||||
apt,
|
apt,
|
||||||
pacman,
|
pacman,
|
||||||
yay,
|
yay,
|
||||||
|
|
@ -785,19 +771,6 @@ 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?;
|
||||||
|
|
@ -1467,16 +1440,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ pub enum Token {
|
||||||
As,
|
As,
|
||||||
Dotfile,
|
Dotfile,
|
||||||
Package,
|
Package,
|
||||||
Brew,
|
|
||||||
Secret,
|
Secret,
|
||||||
Encrypted,
|
Encrypted,
|
||||||
Hook,
|
Hook,
|
||||||
|
|
@ -116,7 +115,6 @@ 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"),
|
||||||
|
|
@ -256,7 +254,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -75,21 +75,28 @@ 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,
|
||||||
cask: pkg.cask.as_ref().map(|s| PackageSpec {
|
tap: s.tap.clone(),
|
||||||
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)),
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ 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);
|
||||||
|
|
@ -67,7 +66,6 @@ impl Parser {
|
||||||
import,
|
import,
|
||||||
dotfile,
|
dotfile,
|
||||||
package,
|
package,
|
||||||
brew,
|
|
||||||
secret,
|
secret,
|
||||||
encrypted,
|
encrypted,
|
||||||
hook,
|
hook,
|
||||||
|
|
@ -223,11 +221,7 @@ 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()
|
Self::ident_parser().or(just(Token::When).to("when".to_string()))
|
||||||
.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>> {
|
||||||
|
|
@ -317,7 +311,6 @@ 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,
|
||||||
|
|
@ -345,7 +338,6 @@ 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,
|
||||||
|
|
@ -355,12 +347,41 @@ 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" => pkg.brew = Some(PackageSpec { name: value }),
|
"brew" => {
|
||||||
"cask" => pkg.cask = Some(PackageSpec { name: value }),
|
pkg.brew = Some(PackageSpec {
|
||||||
"apt" => pkg.apt = Some(PackageSpec { name: value }),
|
name: value,
|
||||||
"pacman" => pkg.pacman = Some(PackageSpec { name: value }),
|
cask: None,
|
||||||
"yay" => pkg.yay = Some(PackageSpec { name: value }),
|
tap: None,
|
||||||
"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),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -371,37 +392,6 @@ 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))
|
||||||
|
|
@ -1067,33 +1057,6 @@ 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";
|
||||||
|
|
|
||||||
|
|
@ -181,20 +181,8 @@ pub fn validate_dotfile_targets(
|
||||||
index_b: j,
|
index_b: j,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Different source + same target = override, j depends on i.
|
// Different source + same target = override, j depends on i
|
||||||
// Layering is supported, but two sources fighting over one file
|
|
||||||
// is usually a mistake, so surface it as a warning (last wins).
|
|
||||||
graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j));
|
graph.add_edge(&format!("dotfile_{}", i), &format!("dotfile_{}", j));
|
||||||
warnings.push(DotfileWarning {
|
|
||||||
message: format!(
|
|
||||||
"'{}' and '{}' both deploy to '{}'; the later entry wins",
|
|
||||||
a.source.display(),
|
|
||||||
b.source.display(),
|
|
||||||
target_a.display()
|
|
||||||
),
|
|
||||||
index_a: i,
|
|
||||||
index_b: j,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -346,9 +334,6 @@ mod tests {
|
||||||
assert!(result.errors.is_empty());
|
assert!(result.errors.is_empty());
|
||||||
// Second entry should come after first
|
// Second entry should come after first
|
||||||
assert_eq!(result.ordered_indices, vec![0, 1]);
|
assert_eq!(result.ordered_indices, vec![0, 1]);
|
||||||
// Two different sources hitting one target is allowed (last wins) but warns.
|
|
||||||
assert_eq!(result.warnings.len(), 1);
|
|
||||||
assert!(result.warnings[0].message.contains("both deploy to"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue