use std::path::{Path, PathBuf};
use crate::Result;
pub trait Pather: Send + Sync {
fn home_dir(&self) -> &Path;
fn dotfiles_root(&self) -> &Path;
fn data_dir(&self) -> &Path;
fn config_dir(&self) -> &Path;
fn cache_dir(&self) -> &Path;
fn xdg_config_home(&self) -> &Path;
fn app_support_dir(&self) -> &Path;
fn shell_dir(&self) -> &Path;
fn pack_path(&self, pack: &str) -> PathBuf {
self.dotfiles_root().join(pack)
}
fn pack_data_dir(&self, pack: &str) -> PathBuf {
self.data_dir().join("packs").join(pack)
}
fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
self.pack_data_dir(pack).join(handler)
}
fn log_dir(&self) -> PathBuf {
self.cache_dir().join("logs")
}
fn init_script_path(&self) -> PathBuf {
self.shell_dir().join("dodot-init.sh")
}
fn deployment_map_path(&self) -> PathBuf {
self.data_dir().join("deployment-map.tsv")
}
fn last_up_path(&self) -> PathBuf {
self.data_dir().join("last-up-at")
}
fn probes_shell_init_dir(&self) -> PathBuf {
self.data_dir().join("probes").join("shell-init")
}
fn probes_brew_cache_dir(&self) -> PathBuf {
self.cache_dir().join("probes").join("brew")
}
fn prompts_path(&self) -> PathBuf {
self.data_dir().join("prompts.json")
}
fn preprocessor_baseline_path(&self, pack: &str, handler: &str, filename: &str) -> PathBuf {
self.cache_dir()
.join("preprocessor")
.join(pack)
.join(handler)
.join(format!("{filename}.json"))
}
fn preprocessor_secrets_sidecar_path(
&self,
pack: &str,
handler: &str,
filename: &str,
) -> PathBuf {
self.cache_dir()
.join("preprocessor")
.join(pack)
.join(handler)
.join(format!("{filename}.secret.json"))
}
fn preprocessor_baseline_dir(&self, pack: &str, handler: &str) -> PathBuf {
self.cache_dir()
.join("preprocessor")
.join(pack)
.join(handler)
}
}
#[derive(Debug, Clone)]
pub struct XdgPather {
home: PathBuf,
dotfiles_root: PathBuf,
data_dir: PathBuf,
config_dir: PathBuf,
cache_dir: PathBuf,
xdg_config_home: PathBuf,
app_support_dir: PathBuf,
shell_dir: PathBuf,
}
#[derive(Debug, Default)]
pub struct XdgPatherBuilder {
home: Option<PathBuf>,
dotfiles_root: Option<PathBuf>,
data_dir: Option<PathBuf>,
config_dir: Option<PathBuf>,
cache_dir: Option<PathBuf>,
xdg_config_home: Option<PathBuf>,
app_support_dir: Option<PathBuf>,
}
impl XdgPatherBuilder {
pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
self.home = Some(path.into());
self
}
pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
self.dotfiles_root = Some(path.into());
self
}
pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.data_dir = Some(path.into());
self
}
pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.config_dir = Some(path.into());
self
}
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(path.into());
self
}
pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
self.xdg_config_home = Some(path.into());
self
}
pub fn app_support_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.app_support_dir = Some(path.into());
self
}
pub fn build(self) -> Result<XdgPather> {
let home = self.home.unwrap_or_else(resolve_home);
let dotfiles_root = self
.dotfiles_root
.unwrap_or_else(|| resolve_dotfiles_root(&home));
let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".config"))
});
let data_dir = self.data_dir.unwrap_or_else(|| {
let xdg_data = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".local").join("share"));
xdg_data.join("dodot")
});
let config_dir = self
.config_dir
.unwrap_or_else(|| xdg_config_home.join("dodot"));
let cache_dir = self.cache_dir.unwrap_or_else(|| {
let xdg_cache = std::env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| home.join(".cache"));
xdg_cache.join("dodot")
});
let shell_dir = data_dir.join("shell");
let app_support_dir = self.app_support_dir.unwrap_or_else(|| {
if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else {
xdg_config_home.clone()
}
});
Ok(XdgPather {
home,
dotfiles_root,
data_dir,
config_dir,
cache_dir,
xdg_config_home,
app_support_dir,
shell_dir,
})
}
}
impl XdgPather {
pub fn builder() -> XdgPatherBuilder {
XdgPatherBuilder::default()
}
pub fn from_env() -> Result<Self> {
Self::builder().build()
}
}
impl Pather for XdgPather {
fn home_dir(&self) -> &Path {
&self.home
}
fn dotfiles_root(&self) -> &Path {
&self.dotfiles_root
}
fn data_dir(&self) -> &Path {
&self.data_dir
}
fn config_dir(&self) -> &Path {
&self.config_dir
}
fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn xdg_config_home(&self) -> &Path {
&self.xdg_config_home
}
fn app_support_dir(&self) -> &Path {
&self.app_support_dir
}
fn shell_dir(&self) -> &Path {
&self.shell_dir
}
}
fn resolve_home() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from("/tmp/dodot-unknown-home")
})
}
fn resolve_dotfiles_root(home: &Path) -> PathBuf {
if let Ok(root) = std::env::var("DOTFILES_ROOT") {
return expand_tilde(&root, home);
}
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
if output.status.success() {
let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !toplevel.is_empty() {
return PathBuf::from(toplevel);
}
}
}
home.join("dotfiles")
}
fn expand_tilde(path: &str, home: &Path) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else if path == "~" {
home.to_path_buf()
} else {
PathBuf::from(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_explicit_paths() {
let pather = XdgPather::builder()
.home("/test/home")
.dotfiles_root("/test/home/dotfiles")
.data_dir("/test/data/dodot")
.config_dir("/test/config/dodot")
.cache_dir("/test/cache/dodot")
.xdg_config_home("/test/home/.config")
.build()
.unwrap();
assert_eq!(pather.home_dir(), Path::new("/test/home"));
assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
}
#[test]
fn shell_dir_derived_from_data_dir() {
let pather = XdgPather::builder()
.home("/h")
.dotfiles_root("/h/dots")
.data_dir("/h/data/dodot")
.build()
.unwrap();
assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
}
#[test]
fn pack_path_joins_dotfiles_root() {
let pather = XdgPather::builder()
.home("/h")
.dotfiles_root("/h/dotfiles")
.build()
.unwrap();
assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
}
#[test]
fn pack_data_dir_structure() {
let pather = XdgPather::builder()
.home("/h")
.data_dir("/h/data/dodot")
.build()
.unwrap();
assert_eq!(
pather.pack_data_dir("vim"),
PathBuf::from("/h/data/dodot/packs/vim")
);
}
#[test]
fn handler_data_dir_structure() {
let pather = XdgPather::builder()
.home("/h")
.data_dir("/h/data/dodot")
.build()
.unwrap();
assert_eq!(
pather.handler_data_dir("vim", "symlink"),
PathBuf::from("/h/data/dodot/packs/vim/symlink")
);
}
#[test]
fn init_script_path() {
let pather = XdgPather::builder()
.home("/h")
.data_dir("/h/data/dodot")
.build()
.unwrap();
assert_eq!(
pather.init_script_path(),
PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
);
}
#[test]
fn expand_tilde_cases() {
let home = Path::new("/home/alice");
assert_eq!(
expand_tilde("~/dotfiles", home),
PathBuf::from("/home/alice/dotfiles")
);
assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
assert_eq!(
expand_tilde("/absolute/path", home),
PathBuf::from("/absolute/path")
);
assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
}
#[test]
fn default_xdg_config_home_is_nested_under_home() {
let pather = XdgPather::builder()
.home("/u")
.dotfiles_root("/u/dotfiles")
.data_dir("/u/.local/share/dodot")
.config_dir("/u/.config/dodot")
.cache_dir("/u/.cache/dodot")
.build()
.unwrap();
let xdg = pather.xdg_config_home();
let home = pather.home_dir();
assert!(
xdg.starts_with(home) || std::env::var("XDG_CONFIG_HOME").is_ok(),
"default xdg_config_home `{}` is not nested under home `{}` \
— adopt's inference assumes XDG ⊆ HOME on the default config; \
update both if this changes",
xdg.display(),
home.display()
);
}
#[test]
fn explicit_xdg_config_home_overrides_default() {
let pather = XdgPather::builder()
.home("/u")
.dotfiles_root("/u/dotfiles")
.xdg_config_home("/somewhere/else/.config")
.build()
.unwrap();
assert_eq!(
pather.xdg_config_home(),
Path::new("/somewhere/else/.config")
);
}
#[test]
fn dotfiles_root_and_data_dir_are_distinct_namespaces() {
let pather = XdgPather::builder()
.home("/u")
.dotfiles_root("/u/dotfiles")
.data_dir("/u/.local/share/dodot")
.build()
.unwrap();
let pack_dir = pather.pack_path("nvim");
let pack_data = pather.pack_data_dir("nvim");
assert!(
!pack_dir.starts_with(&pack_data) && !pack_data.starts_with(&pack_dir),
"pack_path `{}` and pack_data_dir `{}` overlap",
pack_dir.display(),
pack_data.display(),
);
}
#[test]
fn explicit_app_support_dir_overrides_default() {
let pather = XdgPather::builder()
.home("/u")
.dotfiles_root("/u/dotfiles")
.xdg_config_home("/u/.config")
.app_support_dir("/u/Library/Application Support")
.build()
.unwrap();
assert_eq!(
pather.app_support_dir(),
Path::new("/u/Library/Application Support")
);
}
#[test]
fn default_app_support_dir_is_platform_aware() {
let pather = XdgPather::builder()
.home("/u")
.dotfiles_root("/u/dotfiles")
.xdg_config_home("/u/.config")
.build()
.unwrap();
if cfg!(target_os = "macos") {
assert_eq!(
pather.app_support_dir(),
Path::new("/u/Library/Application Support"),
"macOS default should route under $HOME/Library/Application Support"
);
} else {
assert_eq!(
pather.app_support_dir(),
pather.xdg_config_home(),
"non-macOS default should collapse to xdg_config_home"
);
}
}
#[allow(dead_code)]
fn assert_object_safe(_: &dyn Pather) {}
}