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",
|
"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]]
|
[[package]]
|
||||||
name = "discard"
|
name = "discard"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -1043,9 +1022,9 @@ dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
|
||||||
"doot-core",
|
"doot-core",
|
||||||
"doot-lang",
|
"doot-lang",
|
||||||
|
"doot-utils",
|
||||||
"glob",
|
"glob",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
|
@ -1065,8 +1044,8 @@ dependencies = [
|
||||||
"age",
|
"age",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"blake3",
|
"blake3",
|
||||||
"dirs",
|
|
||||||
"doot-lang",
|
"doot-lang",
|
||||||
|
"doot-utils",
|
||||||
"glob",
|
"glob",
|
||||||
"hostname",
|
"hostname",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
|
|
@ -1094,7 +1073,7 @@ dependencies = [
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"blake3",
|
"blake3",
|
||||||
"chumsky",
|
"chumsky",
|
||||||
"dirs",
|
"doot-utils",
|
||||||
"futures-lite 2.6.1",
|
"futures-lite 2.6.1",
|
||||||
"glob",
|
"glob",
|
||||||
"hostname",
|
"hostname",
|
||||||
|
|
@ -1113,6 +1092,10 @@ dependencies = [
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "doot-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
@ -1975,16 +1958,6 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libredox"
|
|
||||||
version = "0.1.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libz-sys"
|
name = "libz-sys"
|
||||||
version = "1.1.23"
|
version = "1.1.23"
|
||||||
|
|
@ -2350,12 +2323,6 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "option-ext"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "5.1.0"
|
version = "5.1.0"
|
||||||
|
|
@ -2726,17 +2693,6 @@ dependencies = [
|
||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ license = "MIT"
|
||||||
repository = "https://github.com/rayandrew/doot"
|
repository = "https://github.com/rayandrew/doot"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
doot-utils = { path = "crates/doot-utils" }
|
||||||
doot-lang = { path = "crates/doot-lang" }
|
doot-lang = { path = "crates/doot-lang" }
|
||||||
doot-core = { path = "crates/doot-core" }
|
doot-core = { path = "crates/doot-core" }
|
||||||
|
|
||||||
|
|
@ -25,7 +26,6 @@ surf = "2"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
age = "0.10"
|
age = "0.10"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
dirs = "6"
|
|
||||||
similar = "2"
|
similar = "2"
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
os_info = "3"
|
os_info = "3"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ name = "doot"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
doot-utils.workspace = true
|
||||||
doot-lang.workspace = true
|
doot-lang.workspace = true
|
||||||
doot-core.workspace = true
|
doot-core.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
|
@ -19,7 +20,6 @@ ratatui.workspace = true
|
||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
dirs.workspace = true
|
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
glob = "0.3"
|
glob = "0.3"
|
||||||
age.workspace = true
|
age.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -211,12 +211,22 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
// Single file handling
|
// Single file handling
|
||||||
// Check for template rendering drift when sync status is Synced and file is a template
|
// Check for template rendering drift when sync status is Synced and file is a template
|
||||||
let mut final_status = status;
|
let mut final_status = status;
|
||||||
if status == SyncStatus::Synced
|
if status == SyncStatus::Synced && dotfile.template {
|
||||||
&& dotfile.template
|
match template_outdated(&state, &preview_engine, &full_source, &dotfile.target) {
|
||||||
&& 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;
|
final_status = SyncStatus::SourceChanged;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match final_status {
|
match final_status {
|
||||||
SyncStatus::Synced => {
|
SyncStatus::Synced => {
|
||||||
|
|
@ -597,7 +607,13 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("\ninstalling {} packages...", to_install.len());
|
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());
|
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")]
|
#[tracing::instrument(level = "trace")]
|
||||||
fn expand_tilde(path: &str) -> PathBuf {
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
if path.starts_with("~/")
|
if let Some(rest) = path.strip_prefix("~/") {
|
||||||
&& let Some(home) = dirs::home_dir()
|
return doot_utils::xdg::home_dir().join(rest);
|
||||||
{
|
|
||||||
return home.join(&path[2..]);
|
|
||||||
}
|
}
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,21 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
dotfile.owner.as_deref(),
|
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 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;
|
sync = SyncStatus::SourceChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (status, extra) = if target.is_symlink() {
|
let (status, extra) = if target.is_symlink() {
|
||||||
let link_target = std::fs::read_link(target).ok();
|
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;
|
use std::process::Command;
|
||||||
|
|
||||||
struct Sandbox {
|
struct Sandbox {
|
||||||
|
|
@ -12,6 +12,10 @@ impl Sandbox {
|
||||||
std::fs::remove_dir_all(&path).unwrap();
|
std::fs::remove_dir_all(&path).unwrap();
|
||||||
}
|
}
|
||||||
std::fs::create_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 }
|
Self { path }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,11 +62,11 @@ impl Sandbox {
|
||||||
std::fs::write(full_path, content).unwrap();
|
std::fs::write(full_path, content).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_symlink(&self, path: &PathBuf) -> bool {
|
fn is_symlink(&self, path: &Path) -> bool {
|
||||||
path.is_symlink()
|
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()
|
std::fs::read_link(path).ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
doot-utils.workspace = true
|
||||||
doot-lang.workspace = true
|
doot-lang.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
age.workspace = true
|
age.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
dirs.workspace = true
|
|
||||||
similar.workspace = true
|
similar.workspace = true
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
os_info.workspace = true
|
os_info.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,7 @@ impl Config {
|
||||||
/// Returns DOOT_HOME if set, otherwise the real home directory.
|
/// Returns DOOT_HOME if set, otherwise the real home directory.
|
||||||
/// Use DOOT_HOME for sandboxed testing.
|
/// Use DOOT_HOME for sandboxed testing.
|
||||||
pub fn home_dir() -> PathBuf {
|
pub fn home_dir() -> PathBuf {
|
||||||
std::env::var("DOOT_HOME")
|
doot_utils::xdg::home_dir()
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::home_dir().unwrap_or_default())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default configuration directory.
|
/// Returns the default configuration directory.
|
||||||
|
|
@ -59,9 +57,7 @@ impl Config {
|
||||||
if let Ok(doot_home) = std::env::var("DOOT_HOME") {
|
if let Ok(doot_home) = std::env::var("DOOT_HOME") {
|
||||||
return PathBuf::from(doot_home).join(".config/doot");
|
return PathBuf::from(doot_home).join(".config/doot");
|
||||||
}
|
}
|
||||||
dirs::config_dir()
|
doot_utils::xdg::config_home().join("doot")
|
||||||
.unwrap_or_else(|| Self::home_dir().join(".config"))
|
|
||||||
.join("doot")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default state directory.
|
/// Returns the default state directory.
|
||||||
|
|
@ -69,10 +65,7 @@ impl Config {
|
||||||
if let Ok(doot_home) = std::env::var("DOOT_HOME") {
|
if let Ok(doot_home) = std::env::var("DOOT_HOME") {
|
||||||
return PathBuf::from(doot_home).join(".local/state/doot");
|
return PathBuf::from(doot_home).join(".local/state/doot");
|
||||||
}
|
}
|
||||||
dirs::state_dir()
|
doot_utils::xdg::state_home().join("doot")
|
||||||
.or_else(dirs::data_local_dir)
|
|
||||||
.unwrap_or_else(|| Self::home_dir().join(".local/state"))
|
|
||||||
.join("doot")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the default source directory.
|
/// Returns the default source directory.
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ impl Deployer {
|
||||||
let dotfile = &dotfiles[idx];
|
let dotfile = &dotfiles[idx];
|
||||||
let r = self
|
let r = self
|
||||||
.deploy_single(dotfile)
|
.deploy_single(dotfile)
|
||||||
.map_err(|e| (dotfile.clone(), e));
|
.map_err(|e| Box::new((dotfile.clone(), e)));
|
||||||
if let Some(pb) = progress {
|
if let Some(pb) = progress {
|
||||||
pb.inc(1);
|
pb.inc(1);
|
||||||
}
|
}
|
||||||
|
|
@ -212,20 +212,22 @@ impl Deployer {
|
||||||
for br in batch_results {
|
for br in batch_results {
|
||||||
match br {
|
match br {
|
||||||
Ok(deployed) => result.deployed.push(deployed),
|
Ok(deployed) => result.deployed.push(deployed),
|
||||||
Err((df, DeployError::TargetExists(p))) => {
|
Err(boxed) => match *boxed {
|
||||||
|
(df, DeployError::TargetExists(p)) => {
|
||||||
result.skipped.push(SkippedFile {
|
result.skipped.push(SkippedFile {
|
||||||
source: df.source,
|
source: df.source,
|
||||||
target: df.target,
|
target: df.target,
|
||||||
reason: format!("target exists: {}", p.display()),
|
reason: format!("target exists: {}", p.display()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err((df, e)) => {
|
(df, e) => {
|
||||||
result.errors.push(DeployErrorInfo {
|
result.errors.push(DeployErrorInfo {
|
||||||
source: df.source,
|
source: df.source,
|
||||||
target: df.target,
|
target: df.target,
|
||||||
error: e.to_string(),
|
error: e.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,30 +67,38 @@ fn build_default_variables() -> HashMap<String, Value> {
|
||||||
let mut vars = HashMap::new();
|
let mut vars = HashMap::new();
|
||||||
|
|
||||||
// Directory paths
|
// Directory paths
|
||||||
if let Some(home) = dirs::home_dir() {
|
vars.insert(
|
||||||
vars.insert("home".to_string(), Value::from(home.display().to_string()));
|
"home".to_string(),
|
||||||
}
|
Value::from(doot_utils::xdg::home_dir().display().to_string()),
|
||||||
if let Some(config) = dirs::config_dir() {
|
);
|
||||||
|
// Use XDG layout on every platform so macOS resolves to ~/.config etc.
|
||||||
|
// instead of ~/Library/Application Support, matching Linux.
|
||||||
vars.insert(
|
vars.insert(
|
||||||
"config_dir".to_string(),
|
"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(
|
vars.insert(
|
||||||
"data_dir".to_string(),
|
"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(
|
vars.insert(
|
||||||
"cache_dir".to_string(),
|
"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".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));
|
vars.insert("arch".to_string(), Value::from(std::env::consts::ARCH));
|
||||||
|
|
||||||
if let Ok(hostname) = hostname::get() {
|
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
|
// config_path(app) - get config directory for an app
|
||||||
env.add_function("config_path", |app: String| -> String {
|
env.add_function("config_path", |app: String| -> String {
|
||||||
dirs::config_dir()
|
doot_utils::xdg::config_home()
|
||||||
.map(|p| p.join(&app).display().to_string())
|
.join(&app)
|
||||||
.unwrap_or_default()
|
.display()
|
||||||
|
.to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Command/Process Functions =====
|
// ===== Command/Process Functions =====
|
||||||
|
|
@ -382,7 +391,7 @@ fn register_functions(env: &mut Environment<'static>) {
|
||||||
#[tracing::instrument(level = "trace")]
|
#[tracing::instrument(level = "trace")]
|
||||||
fn expand_path(s: &str) -> PathBuf {
|
fn expand_path(s: &str) -> PathBuf {
|
||||||
if let Some(stripped) = s.strip_prefix('~') {
|
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))
|
home.join(stripped.strip_prefix('/').unwrap_or(stripped))
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(s)
|
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::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@ use std::process::{Command, Stdio};
|
||||||
pub struct Apt {
|
pub struct Apt {
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
use_sudo: bool,
|
use_sudo: bool,
|
||||||
|
installed_cache: InstalledCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Apt {
|
impl Apt {
|
||||||
|
|
@ -13,9 +15,27 @@ impl Apt {
|
||||||
Self {
|
Self {
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
use_sudo: true,
|
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 {
|
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||||
self.dry_run = dry_run;
|
self.dry_run = dry_run;
|
||||||
self
|
self
|
||||||
|
|
@ -106,7 +126,9 @@ impl PackageManager for Apt {
|
||||||
args.push(pkg);
|
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> {
|
fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> {
|
||||||
|
|
@ -119,7 +141,9 @@ impl PackageManager for Apt {
|
||||||
args.push(pkg);
|
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> {
|
fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> {
|
||||||
|
|
@ -132,13 +156,13 @@ impl PackageManager for Apt {
|
||||||
args.push(pkg);
|
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> {
|
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
|
||||||
let output = Command::new("dpkg").args(["-s", package]).output()?;
|
self.installed_cache.contains(package, Self::load_installed)
|
||||||
|
|
||||||
Ok(output.status.success())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Result<(), PackageError> {
|
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;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Homebrew package manager (macOS/Linux).
|
/// Homebrew package manager (macOS/Linux).
|
||||||
|
#[derive(Default)]
|
||||||
pub struct Brew {
|
pub struct Brew {
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
installed_cache: InstalledCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Brew {
|
impl Brew {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { dry_run: false }
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||||
|
|
@ -16,6 +20,31 @@ impl Brew {
|
||||||
self
|
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))]
|
#[tracing::instrument(skip(self))]
|
||||||
fn run_brew(&self, args: &[&str]) -> Result<(), PackageError> {
|
fn run_brew(&self, args: &[&str]) -> Result<(), PackageError> {
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
|
|
@ -23,12 +52,15 @@ impl Brew {
|
||||||
return Ok(());
|
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 {
|
return Err(PackageError::InstallFailed {
|
||||||
package: args.join(" "),
|
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
|
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> {
|
fn install(&self, packages: &[String]) -> Result<(), PackageError> {
|
||||||
if packages.is_empty() {
|
if packages.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut args = vec!["install"];
|
if self.dry_run {
|
||||||
for pkg in packages {
|
println!("[dry-run] brew install {}", packages.join(" "));
|
||||||
args.push(pkg);
|
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> {
|
fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> {
|
||||||
|
|
@ -80,13 +154,15 @@ impl PackageManager for Brew {
|
||||||
args.push(pkg);
|
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> {
|
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
|
||||||
let output = Command::new("brew").args(["list", package]).output()?;
|
// brew formula/cask names are canonically lowercase; the cache matches
|
||||||
|
// case-insensitively so config names like "ImageMagick" resolve.
|
||||||
Ok(output.status.success())
|
self.installed_cache.contains(package, Self::load_installed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Result<(), PackageError> {
|
fn update(&self) -> Result<(), PackageError> {
|
||||||
|
|
@ -97,9 +173,3 @@ impl PackageManager for Brew {
|
||||||
self.run_brew(&["upgrade"])
|
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),
|
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.
|
/// Common interface for package managers.
|
||||||
pub trait PackageManager: Send + Sync {
|
pub trait PackageManager: Send + Sync {
|
||||||
/// Returns the manager name.
|
/// 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::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
|
@ -6,6 +7,7 @@ use std::process::{Command, Stdio};
|
||||||
pub struct Pacman {
|
pub struct Pacman {
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
use_sudo: bool,
|
use_sudo: bool,
|
||||||
|
installed_cache: InstalledCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pacman {
|
impl Pacman {
|
||||||
|
|
@ -13,9 +15,25 @@ impl Pacman {
|
||||||
Self {
|
Self {
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
use_sudo: true,
|
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 {
|
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||||
self.dry_run = dry_run;
|
self.dry_run = dry_run;
|
||||||
self
|
self
|
||||||
|
|
@ -106,7 +124,9 @@ impl PackageManager for Pacman {
|
||||||
args.push(pkg);
|
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> {
|
fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> {
|
||||||
|
|
@ -119,7 +139,9 @@ impl PackageManager for Pacman {
|
||||||
args.push(pkg);
|
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> {
|
fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> {
|
||||||
|
|
@ -132,13 +154,13 @@ impl PackageManager for Pacman {
|
||||||
args.push(pkg);
|
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> {
|
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
|
||||||
let output = Command::new("pacman").args(["-Q", package]).output()?;
|
self.installed_cache.contains(package, Self::load_installed)
|
||||||
|
|
||||||
Ok(output.status.success())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Result<(), PackageError> {
|
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::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
pub struct Xbps {
|
pub struct Xbps {
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
use_sudo: bool,
|
use_sudo: bool,
|
||||||
|
installed_cache: InstalledCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Xbps {
|
impl Xbps {
|
||||||
|
|
@ -12,9 +14,28 @@ impl Xbps {
|
||||||
Self {
|
Self {
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
use_sudo: true,
|
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 {
|
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||||
self.dry_run = dry_run;
|
self.dry_run = dry_run;
|
||||||
self
|
self
|
||||||
|
|
@ -134,7 +155,9 @@ impl PackageManager for Xbps {
|
||||||
args.push(pkg);
|
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> {
|
fn install_with_sudo(&self, packages: &[String], password: &str) -> Result<(), PackageError> {
|
||||||
|
|
@ -147,7 +170,9 @@ impl PackageManager for Xbps {
|
||||||
args.push(pkg);
|
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> {
|
fn uninstall(&self, packages: &[String]) -> Result<(), PackageError> {
|
||||||
|
|
@ -160,12 +185,13 @@ impl PackageManager for Xbps {
|
||||||
args.push(pkg);
|
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> {
|
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
|
||||||
let output = Command::new("xbps-query").arg(package).output()?;
|
self.installed_cache.contains(package, Self::load_installed)
|
||||||
Ok(output.status.success())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Result<(), PackageError> {
|
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;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Yay AUR helper (Arch Linux).
|
/// Yay AUR helper (Arch Linux).
|
||||||
|
#[derive(Default)]
|
||||||
pub struct Yay {
|
pub struct Yay {
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
|
installed_cache: InstalledCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Yay {
|
impl Yay {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { dry_run: false }
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||||
|
|
@ -16,6 +19,21 @@ impl Yay {
|
||||||
self
|
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))]
|
#[tracing::instrument(skip(self))]
|
||||||
fn run_yay(&self, args: &[&str]) -> Result<(), PackageError> {
|
fn run_yay(&self, args: &[&str]) -> Result<(), PackageError> {
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
|
|
@ -64,7 +82,9 @@ impl PackageManager for Yay {
|
||||||
args.push(pkg);
|
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> {
|
fn install_with_sudo(&self, packages: &[String], _password: &str) -> Result<(), PackageError> {
|
||||||
|
|
@ -82,13 +102,13 @@ impl PackageManager for Yay {
|
||||||
args.push(pkg);
|
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> {
|
fn is_installed(&self, package: &str) -> Result<bool, PackageError> {
|
||||||
let output = Command::new("yay").args(["-Q", package]).output()?;
|
self.installed_cache.contains(package, Self::load_installed)
|
||||||
|
|
||||||
Ok(output.status.success())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&self) -> Result<(), PackageError> {
|
fn update(&self) -> Result<(), PackageError> {
|
||||||
|
|
@ -99,9 +119,3 @@ impl PackageManager for Yay {
|
||||||
self.run_yay(&["-Syu", "--noconfirm"])
|
self.run_yay(&["-Syu", "--noconfirm"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Yay {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
doot-utils.workspace = true
|
||||||
chumsky.workspace = true
|
chumsky.workspace = true
|
||||||
ariadne.workspace = true
|
ariadne.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
@ -15,7 +16,6 @@ futures-lite.workspace = true
|
||||||
surf.workspace = true
|
surf.workspace = true
|
||||||
rayon.workspace = true
|
rayon.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
dirs.workspace = true
|
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
os_info.workspace = true
|
os_info.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -175,22 +175,22 @@ pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
pub fn home_dir() -> Result<Value, EvalError> {
|
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)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
pub fn config_dir() -> Result<Value, EvalError> {
|
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)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
pub fn data_dir() -> Result<Value, EvalError> {
|
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)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
pub fn cache_dir() -> Result<Value, EvalError> {
|
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)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
|
|
@ -327,7 +327,7 @@ fn get_path(args: &[Value]) -> Result<PathBuf, EvalError> {
|
||||||
|
|
||||||
fn expand_path(s: &str) -> PathBuf {
|
fn expand_path(s: &str) -> PathBuf {
|
||||||
if let Some(stripped) = s.strip_prefix('~') {
|
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))
|
home.join(stripped.strip_prefix('/').unwrap_or(stripped))
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(s)
|
PathBuf::from(s)
|
||||||
|
|
|
||||||
|
|
@ -489,8 +489,7 @@ impl Evaluator {
|
||||||
);
|
);
|
||||||
vars.insert(
|
vars.insert(
|
||||||
"DOOT_CONFIG_DIR".to_string(),
|
"DOOT_CONFIG_DIR".to_string(),
|
||||||
dirs::config_dir()
|
doot_utils::xdg::config_home()
|
||||||
.unwrap_or_else(|| Self::home_dir().join(".config"))
|
|
||||||
.join("doot")
|
.join("doot")
|
||||||
.display()
|
.display()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
@ -1432,9 +1431,7 @@ impl Evaluator {
|
||||||
/// Returns DOOT_HOME if set, otherwise the real home directory.
|
/// Returns DOOT_HOME if set, otherwise the real home directory.
|
||||||
#[tracing::instrument(level = "trace")]
|
#[tracing::instrument(level = "trace")]
|
||||||
fn home_dir() -> PathBuf {
|
fn home_dir() -> PathBuf {
|
||||||
std::env::var("DOOT_HOME")
|
doot_utils::xdg::home_dir()
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| dirs::home_dir().unwrap_or_default())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[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.
|
/// Checks for custom distros first, then falls back to os_info.
|
||||||
fn detect_distro() -> String {
|
fn detect_distro() -> String {
|
||||||
// Check for custom distros/environments first (by config directory presence)
|
// Check for custom distros/environments first (by config directory presence)
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
let config_dir = doot_utils::xdg::config_home();
|
||||||
let config_dir = dirs::config_dir().unwrap_or_else(|| home.join(".config"));
|
|
||||||
|
|
||||||
// Omarchy - Arch-based custom environment
|
// Omarchy - Arch-based custom environment
|
||||||
if config_dir.join("omarchy").exists() {
|
if config_dir.join("omarchy").exists() {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
//! This crate provides the lexer, parser, type checker, and evaluator
|
//! This crate provides the lexer, parser, type checker, and evaluator
|
||||||
//! for the doot configuration language.
|
//! 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 ast;
|
||||||
pub mod builtins;
|
pub mod builtins;
|
||||||
pub mod evaluator;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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