diff --git a/Cargo.lock b/Cargo.lock index b2c233c..1baedf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,27 +986,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - [[package]] name = "discard" version = "1.0.4" @@ -1043,9 +1022,9 @@ dependencies = [ "blake3", "clap", "crossterm", - "dirs", "doot-core", "doot-lang", + "doot-utils", "glob", "indexmap", "indicatif", @@ -1065,8 +1044,8 @@ dependencies = [ "age", "anyhow", "blake3", - "dirs", "doot-lang", + "doot-utils", "glob", "hostname", "indicatif", @@ -1094,7 +1073,7 @@ dependencies = [ "async-recursion", "blake3", "chumsky", - "dirs", + "doot-utils", "futures-lite 2.6.1", "glob", "hostname", @@ -1113,6 +1092,10 @@ dependencies = [ "walkdir", ] +[[package]] +name = "doot-utils" +version = "0.1.0" + [[package]] name = "either" version = "1.15.0" @@ -1975,16 +1958,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - [[package]] name = "libz-sys" version = "1.1.23" @@ -2350,12 +2323,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "ordered-float" version = "5.1.0" @@ -2726,17 +2693,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "regex-automata" version = "0.4.14" diff --git a/Cargo.toml b/Cargo.toml index d464a12..6705791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" repository = "https://github.com/rayandrew/doot" [workspace.dependencies] +doot-utils = { path = "crates/doot-utils" } doot-lang = { path = "crates/doot-lang" } doot-core = { path = "crates/doot-core" } @@ -25,7 +26,6 @@ surf = "2" rayon = "1" age = "0.10" walkdir = "2" -dirs = "6" similar = "2" blake3 = "1" os_info = "3" diff --git a/crates/doot-cli/Cargo.toml b/crates/doot-cli/Cargo.toml index 8dfaddd..cdc076f 100644 --- a/crates/doot-cli/Cargo.toml +++ b/crates/doot-cli/Cargo.toml @@ -8,6 +8,7 @@ name = "doot" path = "src/main.rs" [dependencies] +doot-utils.workspace = true doot-lang.workspace = true doot-core.workspace = true clap.workspace = true @@ -19,7 +20,6 @@ ratatui.workspace = true crossterm.workspace = true thiserror.workspace = true anyhow.workspace = true -dirs.workspace = true blake3.workspace = true glob = "0.3" age.workspace = true diff --git a/crates/doot-cli/src/commands/apply.rs b/crates/doot-cli/src/commands/apply.rs index 08fbd21..e7e4e44 100644 --- a/crates/doot-cli/src/commands/apply.rs +++ b/crates/doot-cli/src/commands/apply.rs @@ -211,11 +211,21 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: // Single file handling // Check for template rendering drift when sync status is Synced and file is a template let mut final_status = status; - if status == SyncStatus::Synced - && dotfile.template - && template_outdated(&state, &preview_engine, &full_source, &dotfile.target)? - { - final_status = SyncStatus::SourceChanged; + if status == SyncStatus::Synced && dotfile.template { + match template_outdated(&state, &preview_engine, &full_source, &dotfile.target) { + Ok(true) => final_status = SyncStatus::SourceChanged, + Ok(false) => {} + // A render error must not abort the whole apply. Warn and force a + // redeploy so the deploy phase surfaces the per-file error instead + // of silently leaving the stale file in place. + Err(e) => { + eprintln!( + "warning: template check failed for {}: {e}", + dotfile.target.display() + ); + final_status = SyncStatus::SourceChanged; + } + } } match final_status { @@ -597,7 +607,13 @@ pub fn run(config_path: Option, dry_run: bool, prune: bool) -> anyhow:: } } else { println!("\ninstalling {} packages...", to_install.len()); - manager.install(&to_install)?; + // 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}"); + } + to_install.retain(|pkg| manager.is_installed(pkg).unwrap_or(false)); println!("installed {} packages", to_install.len()); } diff --git a/crates/doot-cli/src/commands/edit.rs b/crates/doot-cli/src/commands/edit.rs index be4c27e..16c1b7e 100644 --- a/crates/doot-cli/src/commands/edit.rs +++ b/crates/doot-cli/src/commands/edit.rs @@ -195,10 +195,8 @@ fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> { #[tracing::instrument(level = "trace")] fn expand_tilde(path: &str) -> PathBuf { - if path.starts_with("~/") - && let Some(home) = dirs::home_dir() - { - return home.join(&path[2..]); + if let Some(rest) = path.strip_prefix("~/") { + return doot_utils::xdg::home_dir().join(rest); } PathBuf::from(path) } diff --git a/crates/doot-cli/src/commands/status.rs b/crates/doot-cli/src/commands/status.rs index fcd1b7d..8084f8c 100644 --- a/crates/doot-cli/src/commands/status.rs +++ b/crates/doot-cli/src/commands/status.rs @@ -56,10 +56,19 @@ pub fn run(config_path: Option) -> anyhow::Result<()> { dotfile.owner.as_deref(), ); - // Check for template rendering changes + // Check for template rendering changes. A render error shouldn't abort + // the whole status report — warn and flag the file as changed instead. if sync == SyncStatus::Synced && dotfile.template { - if template_outdated(&state, &preview_engine, &full_source, target)? { - sync = SyncStatus::SourceChanged; + match template_outdated(&state, &preview_engine, &full_source, target) { + Ok(true) => sync = SyncStatus::SourceChanged, + Ok(false) => {} + Err(e) => { + eprintln!( + "warning: template check failed for {}: {e}", + target.display() + ); + sync = SyncStatus::SourceChanged; + } } } diff --git a/crates/doot-cli/tests/e2e.rs b/crates/doot-cli/tests/e2e.rs index d0b540d..36d0909 100644 --- a/crates/doot-cli/tests/e2e.rs +++ b/crates/doot-cli/tests/e2e.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; struct Sandbox { @@ -12,6 +12,10 @@ impl Sandbox { std::fs::remove_dir_all(&path).unwrap(); } std::fs::create_dir_all(&path).unwrap(); + // Canonicalize so derived paths match what doot produces: on macOS + // temp_dir() (/var/...) is a symlink to /private/var/..., and doot + // resolves its source dir absolutely, yielding the /private path. + let path = path.canonicalize().unwrap(); Self { path } } @@ -58,11 +62,11 @@ impl Sandbox { std::fs::write(full_path, content).unwrap(); } - fn is_symlink(&self, path: &PathBuf) -> bool { + fn is_symlink(&self, path: &Path) -> bool { path.is_symlink() } - fn symlink_target(&self, path: &PathBuf) -> Option { + fn symlink_target(&self, path: &Path) -> Option { std::fs::read_link(path).ok() } } diff --git a/crates/doot-core/Cargo.toml b/crates/doot-core/Cargo.toml index ab591aa..a0e523f 100644 --- a/crates/doot-core/Cargo.toml +++ b/crates/doot-core/Cargo.toml @@ -4,13 +4,13 @@ version.workspace = true edition.workspace = true [dependencies] +doot-utils.workspace = true doot-lang.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true age.workspace = true walkdir.workspace = true -dirs.workspace = true similar.workspace = true blake3.workspace = true os_info.workspace = true diff --git a/crates/doot-core/src/config.rs b/crates/doot-core/src/config.rs index 96b4ddb..84f9a11 100644 --- a/crates/doot-core/src/config.rs +++ b/crates/doot-core/src/config.rs @@ -49,9 +49,7 @@ impl Config { /// Returns DOOT_HOME if set, otherwise the real home directory. /// Use DOOT_HOME for sandboxed testing. pub fn home_dir() -> PathBuf { - std::env::var("DOOT_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default()) + doot_utils::xdg::home_dir() } /// Returns the default configuration directory. @@ -59,9 +57,7 @@ impl Config { if let Ok(doot_home) = std::env::var("DOOT_HOME") { return PathBuf::from(doot_home).join(".config/doot"); } - dirs::config_dir() - .unwrap_or_else(|| Self::home_dir().join(".config")) - .join("doot") + doot_utils::xdg::config_home().join("doot") } /// Returns the default state directory. @@ -69,10 +65,7 @@ impl Config { if let Ok(doot_home) = std::env::var("DOOT_HOME") { return PathBuf::from(doot_home).join(".local/state/doot"); } - dirs::state_dir() - .or_else(dirs::data_local_dir) - .unwrap_or_else(|| Self::home_dir().join(".local/state")) - .join("doot") + doot_utils::xdg::state_home().join("doot") } /// Returns the default source directory. diff --git a/crates/doot-core/src/deploy/mod.rs b/crates/doot-core/src/deploy/mod.rs index 978783e..5fe4874 100644 --- a/crates/doot-core/src/deploy/mod.rs +++ b/crates/doot-core/src/deploy/mod.rs @@ -201,7 +201,7 @@ impl Deployer { let dotfile = &dotfiles[idx]; let r = self .deploy_single(dotfile) - .map_err(|e| (dotfile.clone(), e)); + .map_err(|e| Box::new((dotfile.clone(), e))); if let Some(pb) = progress { pb.inc(1); } @@ -212,20 +212,22 @@ impl Deployer { for br in batch_results { match br { Ok(deployed) => result.deployed.push(deployed), - Err((df, DeployError::TargetExists(p))) => { - result.skipped.push(SkippedFile { - source: df.source, - target: df.target, - reason: format!("target exists: {}", p.display()), - }); - } - Err((df, e)) => { - result.errors.push(DeployErrorInfo { - source: df.source, - target: df.target, - error: e.to_string(), - }); - } + Err(boxed) => match *boxed { + (df, DeployError::TargetExists(p)) => { + result.skipped.push(SkippedFile { + source: df.source, + target: df.target, + reason: format!("target exists: {}", p.display()), + }); + } + (df, e) => { + result.errors.push(DeployErrorInfo { + source: df.source, + target: df.target, + error: e.to_string(), + }); + } + }, } } } diff --git a/crates/doot-core/src/deploy/template.rs b/crates/doot-core/src/deploy/template.rs index 1e36694..9a82bcc 100644 --- a/crates/doot-core/src/deploy/template.rs +++ b/crates/doot-core/src/deploy/template.rs @@ -67,30 +67,38 @@ fn build_default_variables() -> HashMap { let mut vars = HashMap::new(); // Directory paths - if let Some(home) = dirs::home_dir() { - vars.insert("home".to_string(), Value::from(home.display().to_string())); - } - if let Some(config) = dirs::config_dir() { - vars.insert( - "config_dir".to_string(), - Value::from(config.display().to_string()), - ); - } - if let Some(data) = dirs::data_dir() { - vars.insert( - "data_dir".to_string(), - Value::from(data.display().to_string()), - ); - } - if let Some(cache) = dirs::cache_dir() { - vars.insert( - "cache_dir".to_string(), - Value::from(cache.display().to_string()), - ); - } + vars.insert( + "home".to_string(), + Value::from(doot_utils::xdg::home_dir().display().to_string()), + ); + // Use XDG layout on every platform so macOS resolves to ~/.config etc. + // instead of ~/Library/Application Support, matching Linux. + vars.insert( + "config_dir".to_string(), + Value::from(doot_utils::xdg::config_home().display().to_string()), + ); + vars.insert( + "data_dir".to_string(), + Value::from(doot_utils::xdg::data_home().display().to_string()), + ); + vars.insert( + "cache_dir".to_string(), + Value::from(doot_utils::xdg::cache_home().display().to_string()), + ); - // System info + // System info. Note: `os` is usually overridden by the doot `Os` enum + // (rendering as "MacOS"/"Linux"/...), so also expose stable lower/upper + // string forms that are never overridden, for case-insensitive checks. + // (minijinja's `| lower` / `| upper` filters work on these too.) vars.insert("os".to_string(), Value::from(std::env::consts::OS)); + vars.insert( + "os_lower".to_string(), + Value::from(std::env::consts::OS.to_lowercase()), + ); + vars.insert( + "os_upper".to_string(), + Value::from(std::env::consts::OS.to_uppercase()), + ); vars.insert("arch".to_string(), Value::from(std::env::consts::ARCH)); if let Ok(hostname) = hostname::get() { @@ -219,9 +227,10 @@ fn register_functions(env: &mut Environment<'static>) { // config_path(app) - get config directory for an app env.add_function("config_path", |app: String| -> String { - dirs::config_dir() - .map(|p| p.join(&app).display().to_string()) - .unwrap_or_default() + doot_utils::xdg::config_home() + .join(&app) + .display() + .to_string() }); // ===== Command/Process Functions ===== @@ -382,7 +391,7 @@ fn register_functions(env: &mut Environment<'static>) { #[tracing::instrument(level = "trace")] fn expand_path(s: &str) -> PathBuf { if let Some(stripped) = s.strip_prefix('~') { - let home = dirs::home_dir().unwrap_or_default(); + let home = doot_utils::xdg::home_dir(); home.join(stripped.strip_prefix('/').unwrap_or(stripped)) } else { PathBuf::from(s) diff --git a/crates/doot-core/src/package/apt.rs b/crates/doot-core/src/package/apt.rs index a44d23d..e669b60 100644 --- a/crates/doot-core/src/package/apt.rs +++ b/crates/doot-core/src/package/apt.rs @@ -1,4 +1,5 @@ -use super::{PackageError, PackageManager}; +use super::{InstalledCache, PackageError, PackageManager}; +use std::collections::HashSet; use std::io::Write; use std::process::{Command, Stdio}; @@ -6,6 +7,7 @@ use std::process::{Command, Stdio}; pub struct Apt { dry_run: bool, use_sudo: bool, + installed_cache: InstalledCache, } impl Apt { @@ -13,9 +15,27 @@ impl Apt { Self { dry_run: false, use_sudo: true, + installed_cache: InstalledCache::default(), } } + /// Loads all installed package names in one `dpkg-query` call. + fn load_installed() -> Result, PackageError> { + let output = Command::new("dpkg-query") + .args(["-W", "-f", "${Package}\n"]) + .output()?; + let mut set = HashSet::new(); + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let name = line.trim(); + if !name.is_empty() { + set.insert(name.to_lowercase()); + } + } + } + Ok(set) + } + pub fn dry_run(mut self, dry_run: bool) -> Self { self.dry_run = dry_run; self @@ -106,7 +126,9 @@ impl PackageManager for Apt { args.push(pkg); } - self.run_apt(&args) + let result = self.run_apt(&args); + self.installed_cache.invalidate(); + result } fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> { @@ -119,7 +141,9 @@ impl PackageManager for Apt { args.push(pkg); } - self.run_apt_with_password(&args, password) + let result = self.run_apt_with_password(&args, password); + self.installed_cache.invalidate(); + result } fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> { @@ -132,13 +156,13 @@ impl PackageManager for Apt { args.push(pkg); } - self.run_apt(&args) + let result = self.run_apt(&args); + self.installed_cache.invalidate(); + result } fn is_installed(&self, package: &str) -> Result { - let output = Command::new("dpkg").args(["-s", package]).output()?; - - Ok(output.status.success()) + self.installed_cache.contains(package, Self::load_installed) } fn update(&self) -> Result<(), PackageError> { diff --git a/crates/doot-core/src/package/brew.rs b/crates/doot-core/src/package/brew.rs index a764422..2ba5906 100644 --- a/crates/doot-core/src/package/brew.rs +++ b/crates/doot-core/src/package/brew.rs @@ -1,14 +1,18 @@ -use super::{PackageError, PackageManager}; +use super::{InstalledCache, PackageError, PackageManager}; +use rayon::prelude::*; +use std::collections::HashSet; use std::process::Command; /// Homebrew package manager (macOS/Linux). +#[derive(Default)] pub struct Brew { dry_run: bool, + installed_cache: InstalledCache, } impl Brew { pub fn new() -> Self { - Self { dry_run: false } + Self::default() } pub fn dry_run(mut self, dry_run: bool) -> Self { @@ -16,6 +20,31 @@ impl Brew { self } + /// Loads all installed formula and cask names (lowercased) in two `brew list` + /// calls instead of one per package. + fn load_installed() -> Result, PackageError> { + let mut set = HashSet::new(); + for kind in ["--formula", "--cask"] { + let output = Command::new("brew").args(["list", kind, "-1"]).output()?; + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let name = line.trim(); + if !name.is_empty() { + set.insert(name.to_lowercase()); + } + } + } + } + Ok(set) + } + + /// Runs a brew command, streaming its output live to the terminal. + /// + /// Brew installs run in two phases: it first *fetches* every bottle, then + /// *pours* each one into the Cellar. Capturing output with `.output()` hides + /// the pour phase and any errors/prompts, making a partial or failed install + /// look like it succeeded. Inheriting stdio shows real progress (the `Pouring` + /// / 🍺 lines) and surfaces failures instead of swallowing them. #[tracing::instrument(skip(self))] fn run_brew(&self, args: &[&str]) -> Result<(), PackageError> { if self.dry_run { @@ -23,12 +52,15 @@ impl Brew { return Ok(()); } - let output = Command::new("brew").args(args).output()?; + let status = Command::new("brew").args(args).status()?; - if !output.status.success() { + if !status.success() { return Err(PackageError::InstallFailed { package: args.join(" "), - message: String::from_utf8_lossy(&output.stderr).to_string(), + message: match status.code() { + Some(code) => format!("brew {} exited with status {code}", args.join(" ")), + None => format!("brew {} terminated by signal", args.join(" ")), + }, }); } @@ -53,17 +85,59 @@ impl PackageManager for Brew { false } + /// Installs packages, continuing past individual failures. + /// + /// Bottles are fetched in parallel first (`brew fetch` does not take the + /// global install lock, so concurrent downloads are safe), then each package + /// 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(()); } - let mut args = vec!["install"]; - for pkg in packages { - args.push(pkg); + if self.dry_run { + println!("[dry-run] brew install {}", packages.join(" ")); + return Ok(()); } - self.run_brew(&args) + // 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() + .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 {} packages failed: {}", + failed.len(), + packages.len(), + failed.join(", ") + ), + }) + } } fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> { @@ -80,13 +154,15 @@ impl PackageManager for Brew { args.push(pkg); } - self.run_brew(&args) + let result = self.run_brew(&args); + self.installed_cache.invalidate(); + result } fn is_installed(&self, package: &str) -> Result { - let output = Command::new("brew").args(["list", package]).output()?; - - Ok(output.status.success()) + // brew formula/cask names are canonically lowercase; the cache matches + // case-insensitively so config names like "ImageMagick" resolve. + self.installed_cache.contains(package, Self::load_installed) } fn update(&self) -> Result<(), PackageError> { @@ -97,9 +173,3 @@ impl PackageManager for Brew { self.run_brew(&["upgrade"]) } } - -impl Default for Brew { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/doot-core/src/package/mod.rs b/crates/doot-core/src/package/mod.rs index ff3f41d..71717cc 100644 --- a/crates/doot-core/src/package/mod.rs +++ b/crates/doot-core/src/package/mod.rs @@ -32,6 +32,37 @@ pub enum PackageError { IoError(#[from] std::io::Error), } +/// Caches the set of installed package names so membership checks don't spawn one +/// query process per package (e.g. `brew list ` ~0.5s each). The set is +/// loaded lazily on first use and invalidated after install/uninstall. Names are +/// stored and compared lowercased for case-insensitive matching. +#[derive(Default)] +pub(crate) struct InstalledCache { + inner: Mutex>>, +} + +impl InstalledCache { + /// Returns whether `name` is installed, loading the full set once via `load`. + pub(crate) fn contains( + &self, + name: &str, + load: impl FnOnce() -> Result, PackageError>, + ) -> Result { + let mut guard = self.inner.lock().unwrap(); + if guard.is_none() { + *guard = Some(load()?); + } + Ok(guard.as_ref().unwrap().contains(&name.to_lowercase())) + } + + /// Drops the cached set; the next `contains` reloads it. + pub(crate) fn invalidate(&self) { + if let Ok(mut guard) = self.inner.lock() { + *guard = None; + } + } +} + /// Common interface for package managers. pub trait PackageManager: Send + Sync { /// Returns the manager name. diff --git a/crates/doot-core/src/package/pacman.rs b/crates/doot-core/src/package/pacman.rs index 9a3b035..b075b96 100644 --- a/crates/doot-core/src/package/pacman.rs +++ b/crates/doot-core/src/package/pacman.rs @@ -1,4 +1,5 @@ -use super::{PackageError, PackageManager}; +use super::{InstalledCache, PackageError, PackageManager}; +use std::collections::HashSet; use std::io::Write; use std::process::{Command, Stdio}; @@ -6,6 +7,7 @@ use std::process::{Command, Stdio}; pub struct Pacman { dry_run: bool, use_sudo: bool, + installed_cache: InstalledCache, } impl Pacman { @@ -13,9 +15,25 @@ impl Pacman { Self { dry_run: false, use_sudo: true, + installed_cache: InstalledCache::default(), } } + /// Loads all installed package names in one `pacman -Qq` call. + fn load_installed() -> Result, PackageError> { + let output = Command::new("pacman").arg("-Qq").output()?; + let mut set = HashSet::new(); + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let name = line.trim(); + if !name.is_empty() { + set.insert(name.to_lowercase()); + } + } + } + Ok(set) + } + pub fn dry_run(mut self, dry_run: bool) -> Self { self.dry_run = dry_run; self @@ -106,7 +124,9 @@ impl PackageManager for Pacman { args.push(pkg); } - self.run_pacman(&args) + let result = self.run_pacman(&args); + self.installed_cache.invalidate(); + result } fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> { @@ -119,7 +139,9 @@ impl PackageManager for Pacman { args.push(pkg); } - self.run_pacman_with_password(&args, password) + let result = self.run_pacman_with_password(&args, password); + self.installed_cache.invalidate(); + result } fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> { @@ -132,13 +154,13 @@ impl PackageManager for Pacman { args.push(pkg); } - self.run_pacman(&args) + let result = self.run_pacman(&args); + self.installed_cache.invalidate(); + result } fn is_installed(&self, package: &str) -> Result { - let output = Command::new("pacman").args(["-Q", package]).output()?; - - Ok(output.status.success()) + self.installed_cache.contains(package, Self::load_installed) } fn update(&self) -> Result<(), PackageError> { diff --git a/crates/doot-core/src/package/xbps.rs b/crates/doot-core/src/package/xbps.rs index d81d08a..63a6785 100644 --- a/crates/doot-core/src/package/xbps.rs +++ b/crates/doot-core/src/package/xbps.rs @@ -1,10 +1,12 @@ -use super::{PackageError, PackageManager}; +use super::{InstalledCache, PackageError, PackageManager}; +use std::collections::HashSet; use std::io::Write; use std::process::{Command, Stdio}; pub struct Xbps { dry_run: bool, use_sudo: bool, + installed_cache: InstalledCache, } impl Xbps { @@ -12,9 +14,28 @@ impl Xbps { Self { dry_run: false, use_sudo: true, + installed_cache: InstalledCache::default(), } } + /// Loads all installed package names in one `xbps-query -l` call. Each line is + /// `ii -_ `; the package name is everything before + /// the last `-` of the second column. + fn load_installed() -> Result, PackageError> { + let output = Command::new("xbps-query").arg("-l").output()?; + let mut set = HashSet::new(); + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some(pkgver) = line.split_whitespace().nth(1) + && let Some((name, _version)) = pkgver.rsplit_once('-') + { + set.insert(name.to_lowercase()); + } + } + } + Ok(set) + } + pub fn dry_run(mut self, dry_run: bool) -> Self { self.dry_run = dry_run; self @@ -134,7 +155,9 @@ impl PackageManager for Xbps { args.push(pkg); } - self.run_xbps("xbps-install", &args) + let result = self.run_xbps("xbps-install", &args); + self.installed_cache.invalidate(); + result } fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> { @@ -147,7 +170,9 @@ impl PackageManager for Xbps { args.push(pkg); } - self.run_xbps_with_password("xbps-install", &args, password) + let result = self.run_xbps_with_password("xbps-install", &args, password); + self.installed_cache.invalidate(); + result } fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> { @@ -160,12 +185,13 @@ impl PackageManager for Xbps { args.push(pkg); } - self.run_xbps("xbps-remove", &args) + let result = self.run_xbps("xbps-remove", &args); + self.installed_cache.invalidate(); + result } fn is_installed(&self, package: &str) -> Result { - let output = Command::new("xbps-query").arg(package).output()?; - Ok(output.status.success()) + self.installed_cache.contains(package, Self::load_installed) } fn update(&self) -> Result<(), PackageError> { diff --git a/crates/doot-core/src/package/yay.rs b/crates/doot-core/src/package/yay.rs index 2367a39..b7b8ecb 100644 --- a/crates/doot-core/src/package/yay.rs +++ b/crates/doot-core/src/package/yay.rs @@ -1,14 +1,17 @@ -use super::{PackageError, PackageManager}; +use super::{InstalledCache, PackageError, PackageManager}; +use std::collections::HashSet; use std::process::Command; /// Yay AUR helper (Arch Linux). +#[derive(Default)] pub struct Yay { dry_run: bool, + installed_cache: InstalledCache, } impl Yay { pub fn new() -> Self { - Self { dry_run: false } + Self::default() } pub fn dry_run(mut self, dry_run: bool) -> Self { @@ -16,6 +19,21 @@ impl Yay { self } + /// Loads all installed package names in one `yay -Qq` call. + fn load_installed() -> Result, PackageError> { + let output = Command::new("yay").arg("-Qq").output()?; + let mut set = HashSet::new(); + if output.status.success() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let name = line.trim(); + if !name.is_empty() { + set.insert(name.to_lowercase()); + } + } + } + Ok(set) + } + #[tracing::instrument(skip(self))] fn run_yay(&self, args: &[&str]) -> Result<(), PackageError> { if self.dry_run { @@ -64,7 +82,9 @@ impl PackageManager for Yay { args.push(pkg); } - self.run_yay(&args) + let result = self.run_yay(&args); + self.installed_cache.invalidate(); + result } fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> { @@ -82,13 +102,13 @@ impl PackageManager for Yay { args.push(pkg); } - self.run_yay(&args) + let result = self.run_yay(&args); + self.installed_cache.invalidate(); + result } fn is_installed(&self, package: &str) -> Result { - let output = Command::new("yay").args(["-Q", package]).output()?; - - Ok(output.status.success()) + self.installed_cache.contains(package, Self::load_installed) } fn update(&self) -> Result<(), PackageError> { @@ -99,9 +119,3 @@ impl PackageManager for Yay { self.run_yay(&["-Syu", "--noconfirm"]) } } - -impl Default for Yay { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/doot-lang/Cargo.toml b/crates/doot-lang/Cargo.toml index de419b6..365b4dd 100644 --- a/crates/doot-lang/Cargo.toml +++ b/crates/doot-lang/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +doot-utils.workspace = true chumsky.workspace = true ariadne.workspace = true serde.workspace = true @@ -15,7 +16,6 @@ futures-lite.workspace = true surf.workspace = true rayon.workspace = true walkdir.workspace = true -dirs.workspace = true blake3.workspace = true os_info.workspace = true thiserror.workspace = true diff --git a/crates/doot-lang/src/builtins/io.rs b/crates/doot-lang/src/builtins/io.rs index 6357d9d..c899425 100644 --- a/crates/doot-lang/src/builtins/io.rs +++ b/crates/doot-lang/src/builtins/io.rs @@ -175,22 +175,22 @@ pub fn path_extension(args: &[Value]) -> Result { #[tracing::instrument(level = "trace", skip_all)] pub fn home_dir() -> Result { - Ok(Value::Path(dirs::home_dir().unwrap_or_default())) + Ok(Value::Path(doot_utils::xdg::home_dir())) } #[tracing::instrument(level = "trace", skip_all)] pub fn config_dir() -> Result { - Ok(Value::Path(dirs::config_dir().unwrap_or_default())) + Ok(Value::Path(doot_utils::xdg::config_home())) } #[tracing::instrument(level = "trace", skip_all)] pub fn data_dir() -> Result { - Ok(Value::Path(dirs::data_dir().unwrap_or_default())) + Ok(Value::Path(doot_utils::xdg::data_home())) } #[tracing::instrument(level = "trace", skip_all)] pub fn cache_dir() -> Result { - Ok(Value::Path(dirs::cache_dir().unwrap_or_default())) + Ok(Value::Path(doot_utils::xdg::cache_home())) } #[tracing::instrument(level = "trace", skip_all)] @@ -327,7 +327,7 @@ fn get_path(args: &[Value]) -> Result { fn expand_path(s: &str) -> PathBuf { if let Some(stripped) = s.strip_prefix('~') { - let home = dirs::home_dir().unwrap_or_default(); + let home = doot_utils::xdg::home_dir(); home.join(stripped.strip_prefix('/').unwrap_or(stripped)) } else { PathBuf::from(s) diff --git a/crates/doot-lang/src/evaluator.rs b/crates/doot-lang/src/evaluator.rs index 9550a4a..f77f3ac 100644 --- a/crates/doot-lang/src/evaluator.rs +++ b/crates/doot-lang/src/evaluator.rs @@ -489,8 +489,7 @@ impl Evaluator { ); vars.insert( "DOOT_CONFIG_DIR".to_string(), - dirs::config_dir() - .unwrap_or_else(|| Self::home_dir().join(".config")) + doot_utils::xdg::config_home() .join("doot") .display() .to_string(), @@ -1432,9 +1431,7 @@ impl Evaluator { /// Returns DOOT_HOME if set, otherwise the real home directory. #[tracing::instrument(level = "trace")] fn home_dir() -> PathBuf { - std::env::var("DOOT_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default()) + doot_utils::xdg::home_dir() } #[tracing::instrument(level = "trace", skip_all)] @@ -1543,8 +1540,7 @@ fn detect_pkg_manager() -> String { /// Checks for custom distros first, then falls back to os_info. fn detect_distro() -> String { // Check for custom distros/environments first (by config directory presence) - let home = dirs::home_dir().unwrap_or_default(); - let config_dir = dirs::config_dir().unwrap_or_else(|| home.join(".config")); + let config_dir = doot_utils::xdg::config_home(); // Omarchy - Arch-based custom environment if config_dir.join("omarchy").exists() { diff --git a/crates/doot-lang/src/lib.rs b/crates/doot-lang/src/lib.rs index 0aca0b9..bac204d 100644 --- a/crates/doot-lang/src/lib.rs +++ b/crates/doot-lang/src/lib.rs @@ -3,6 +3,11 @@ //! This crate provides the lexer, parser, type checker, and evaluator //! for the doot configuration language. +// chumsky 0.9's `Simple` error type is inherently large (~152 bytes) and +// is fixed by the parser-combinator API, so it cannot be boxed at the `select!` +// call sites. This lint is unactionable here. +#![allow(clippy::result_large_err)] + pub mod ast; pub mod builtins; pub mod evaluator; diff --git a/crates/doot-lang/src/parser.rs b/crates/doot-lang/src/parser.rs index 7ca82bf..67ceec3 100644 --- a/crates/doot-lang/src/parser.rs +++ b/crates/doot-lang/src/parser.rs @@ -996,6 +996,42 @@ fn expr_to_string_list(expr: &Expr) -> Vec { } } +fn expr_to_permission_rules(expr: &Expr) -> Vec { + match expr { + // Single mode: permissions = 0o755 + Expr::Literal(Literal::Int(mode)) => { + vec![PermissionRule::Single(*mode as u32)] + } + // Array of rules: permissions = [["*.sh", 0o755], ["secret/*", 0o600]] + Expr::List(items) => items + .iter() + .filter_map(|e| { + match e { + // [pattern, mode] pair + Expr::List(pair) if pair.len() == 2 => { + if let ( + Expr::Literal(Literal::Str(pattern)), + Expr::Literal(Literal::Int(mode)), + ) = (&pair[0], &pair[1]) + { + Some(PermissionRule::Pattern { + pattern: pattern.clone(), + mode: *mode as u32, + }) + } else { + None + } + } + // Single mode in array (less common but supported) + Expr::Literal(Literal::Int(mode)) => Some(PermissionRule::Single(*mode as u32)), + _ => None, + } + }) + .collect(), + _ => Vec::new(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1050,39 +1086,3 @@ mod tests { } } } - -fn expr_to_permission_rules(expr: &Expr) -> Vec { - match expr { - // Single mode: permissions = 0o755 - Expr::Literal(Literal::Int(mode)) => { - vec![PermissionRule::Single(*mode as u32)] - } - // Array of rules: permissions = [["*.sh", 0o755], ["secret/*", 0o600]] - Expr::List(items) => items - .iter() - .filter_map(|e| { - match e { - // [pattern, mode] pair - Expr::List(pair) if pair.len() == 2 => { - if let ( - Expr::Literal(Literal::Str(pattern)), - Expr::Literal(Literal::Int(mode)), - ) = (&pair[0], &pair[1]) - { - Some(PermissionRule::Pattern { - pattern: pattern.clone(), - mode: *mode as u32, - }) - } else { - None - } - } - // Single mode in array (less common but supported) - Expr::Literal(Literal::Int(mode)) => Some(PermissionRule::Single(*mode as u32)), - _ => None, - } - }) - .collect(), - _ => Vec::new(), - } -} diff --git a/crates/doot-utils/Cargo.toml b/crates/doot-utils/Cargo.toml new file mode 100644 index 0000000..a20e7f7 --- /dev/null +++ b/crates/doot-utils/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "doot-utils" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] diff --git a/crates/doot-utils/src/lib.rs b/crates/doot-utils/src/lib.rs new file mode 100644 index 0000000..2657b32 --- /dev/null +++ b/crates/doot-utils/src/lib.rs @@ -0,0 +1,3 @@ +//! Shared low-level utilities for doot crates. + +pub mod xdg; diff --git a/crates/doot-utils/src/xdg.rs b/crates/doot-utils/src/xdg.rs new file mode 100644 index 0000000..3d1684d --- /dev/null +++ b/crates/doot-utils/src/xdg.rs @@ -0,0 +1,46 @@ +//! XDG base directory resolution. +//! +//! Unlike the `dirs` crate, these helpers use the XDG layout on every platform. +//! On macOS that means `~/.config`, `~/.local/share`, and `~/.cache` instead of +//! `~/Library/Application Support` / `~/Library/Caches`, keeping deployed dotfile +//! targets consistent with Linux. + +use std::path::PathBuf; + +/// Returns DOOT_HOME if set, otherwise the real home directory (`$HOME`). +pub fn home_dir() -> PathBuf { + std::env::var_os("DOOT_HOME") + .filter(|v| !v.is_empty()) + .or_else(|| std::env::var_os("HOME").filter(|v| !v.is_empty())) + .map(PathBuf::from) + .unwrap_or_default() +} + +/// Returns the XDG base config directory (`$XDG_CONFIG_HOME` or `~/.config`). +pub fn config_home() -> PathBuf { + resolve("XDG_CONFIG_HOME", ".config") +} + +/// Returns the XDG base data directory (`$XDG_DATA_HOME` or `~/.local/share`). +pub fn data_home() -> PathBuf { + resolve("XDG_DATA_HOME", ".local/share") +} + +/// Returns the XDG base cache directory (`$XDG_CACHE_HOME` or `~/.cache`). +pub fn cache_home() -> PathBuf { + resolve("XDG_CACHE_HOME", ".cache") +} + +/// Returns the XDG base state directory (`$XDG_STATE_HOME` or `~/.local/state`). +pub fn state_home() -> PathBuf { + resolve("XDG_STATE_HOME", ".local/state") +} + +/// Resolves an XDG base directory: the env var if set to an absolute path, +/// otherwise `home/`. Per the XDG spec, relative values are ignored. +fn resolve(env: &str, fallback: &str) -> PathBuf { + match std::env::var_os(env) { + Some(val) if !val.is_empty() && PathBuf::from(&val).is_absolute() => PathBuf::from(val), + _ => home_dir().join(fallback), + } +}