feat(utils): introduce doot-utils crate with XDG directory helpers

- Add new `doot-utils` crate with `xdg` module for consistent cross-platform directory resolution
- Replace `dirs` crate usage with `doot_utils::xdg` functions in cli, core, and lang crates
- Use XDG layout on all platforms (including macOS) for config, data, cache, and state directories
- Add home_dir(), config_home(), data_home(), cache_home(), and state_home() helpers
- Update dependencies to use doot-utils workspace reference
- Remove unused dirs and related crates from Cargo.lock
- Improve error handling in template rendering and package installation
- Add InstalledCache for package managers to reduce process spawning
- Optimize brew package installation with parallel fetching and sequential installing
- Fix path canonicalization in e2e tests for consistent symlink handling
- Add clippy allowance for large parser errors in doot-lang
This commit is contained in:
Ray Andrew 2026-06-09 23:24:46 -07:00
parent 77b25771c3
commit 0eb4d38392
Signed by: rayandrew
SSH key fingerprint: SHA256:iGurnBY6QgoHsQWxP3NgvMEA4F3GjTcszIJnLk2jinw
25 changed files with 454 additions and 222 deletions

58
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -211,12 +211,22 @@ pub fn run(config_path: Option<PathBuf>, 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)?
{
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 {
SyncStatus::Synced => {
@ -597,7 +607,13 @@ pub fn run(config_path: Option<PathBuf>, 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());
}

View file

@ -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)
}

View file

@ -56,12 +56,21 @@ pub fn run(config_path: Option<PathBuf>) -> 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)? {
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;
}
}
}
let (status, extra) = if target.is_symlink() {
let link_target = std::fs::read_link(target).ok();

View file

@ -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<PathBuf> {
fn symlink_target(&self, path: &Path) -> Option<PathBuf> {
std::fs::read_link(path).ok()
}
}

View file

@ -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

View file

@ -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.

View file

@ -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))) => {
Err(boxed) => match *boxed {
(df, DeployError::TargetExists(p)) => {
result.skipped.push(SkippedFile {
source: df.source,
target: df.target,
reason: format!("target exists: {}", p.display()),
});
}
Err((df, e)) => {
(df, e) => {
result.errors.push(DeployErrorInfo {
source: df.source,
target: df.target,
error: e.to_string(),
});
}
},
}
}
}

View file

@ -67,30 +67,38 @@ fn build_default_variables() -> HashMap<String, Value> {
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(
"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(config.display().to_string()),
Value::from(doot_utils::xdg::config_home().display().to_string()),
);
}
if let Some(data) = dirs::data_dir() {
vars.insert(
"data_dir".to_string(),
Value::from(data.display().to_string()),
Value::from(doot_utils::xdg::data_home().display().to_string()),
);
}
if let Some(cache) = dirs::cache_dir() {
vars.insert(
"cache_dir".to_string(),
Value::from(cache.display().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)

View file

@ -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<HashSet<String>, 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<bool, PackageError> {
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> {

View file

@ -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<HashSet<String>, 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<bool, PackageError> {
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()
}
}

View file

@ -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 <pkg>` ~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<Option<HashSet<String>>>,
}
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<HashSet<String>, PackageError>,
) -> Result<bool, PackageError> {
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.

View file

@ -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<HashSet<String>, 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<bool, PackageError> {
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> {

View file

@ -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 <name>-<version>_<rev> <desc>`; the package name is everything before
/// the last `-` of the second column.
fn load_installed() -> Result<HashSet<String>, 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<bool, PackageError> {
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> {

View file

@ -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<HashSet<String>, 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<bool, PackageError> {
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()
}
}

View file

@ -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

View file

@ -175,22 +175,22 @@ pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
#[tracing::instrument(level = "trace", skip_all)]
pub fn home_dir() -> Result<Value, EvalError> {
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<Value, EvalError> {
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<Value, EvalError> {
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<Value, EvalError> {
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<PathBuf, EvalError> {
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)

View file

@ -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() {

View file

@ -3,6 +3,11 @@
//! This crate provides the lexer, parser, type checker, and evaluator
//! for the doot configuration language.
// chumsky 0.9's `Simple<Token>` 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;

View file

@ -996,6 +996,42 @@ fn expr_to_string_list(expr: &Expr) -> Vec<String> {
}
}
fn expr_to_permission_rules(expr: &Expr) -> Vec<PermissionRule> {
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<PermissionRule> {
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(),
}
}

View file

@ -0,0 +1,8 @@
[package]
name = "doot-utils"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]

View file

@ -0,0 +1,3 @@
//! Shared low-level utilities for doot crates.
pub mod xdg;

View file

@ -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/<fallback>`. 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),
}
}