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:
parent
77b25771c3
commit
0eb4d38392
25 changed files with 454 additions and 222 deletions
58
Cargo.lock
generated
58
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
crates/doot-utils/Cargo.toml
Normal file
8
crates/doot-utils/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "doot-utils"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
3
crates/doot-utils/src/lib.rs
Normal file
3
crates/doot-utils/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Shared low-level utilities for doot crates.
|
||||
|
||||
pub mod xdg;
|
||||
46
crates/doot-utils/src/xdg.rs
Normal file
46
crates/doot-utils/src/xdg.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue