#![warn(missing_docs, missing_debug_implementations)]
use std::{env, fs, io, path::{Path, PathBuf}};
#[derive(Debug)]
pub enum XdgError {
HomeDirNotFound,
Io(io::Error),
RuntimeDirNotSet,
}
impl std::fmt::Display for XdgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
XdgError::HomeDirNotFound => write!(f, "The $HOME environment variable is not set"),
XdgError::Io(e) => write!(f, "I/O error: {e}"),
XdgError::RuntimeDirNotSet => write!(f, "$XDG_RUNTIME_DIR is not set"),
}
}
}
impl std::error::Error for XdgError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
if let XdgError::Io(e) = self { Some(e) } else { None }
}
}
impl From<io::Error> for XdgError {
fn from(e: io::Error) -> Self { XdgError::Io(e) }
}
#[derive(Debug, Clone)]
pub struct XdgDirs {
home: PathBuf,
}
impl XdgDirs {
pub fn new() -> Self {
Self::try_new().expect(
"xdge: $HOME is not set; cannot determine any XDG base directory",
)
}
pub fn try_new() -> Result<Self, XdgError> {
let home = home_dir().ok_or(XdgError::HomeDirNotFound)?;
Ok(Self { home })
}
pub fn new_with_home(home: PathBuf) -> Self {
Self { home }
}
pub fn data_home(&self) -> PathBuf {
resolve_single("XDG_DATA_HOME", self.home.join(".local/share"))
}
pub fn config_home(&self) -> PathBuf {
resolve_single("XDG_CONFIG_HOME", self.home.join(".config"))
}
pub fn state_home(&self) -> PathBuf {
resolve_single("XDG_STATE_HOME", self.home.join(".local/state"))
}
pub fn cache_home(&self) -> PathBuf {
resolve_single("XDG_CACHE_HOME", self.home.join(".cache"))
}
pub fn user_bin_dir(&self) -> PathBuf {
self.home.join(".local/bin")
}
pub fn runtime_dir(&self) -> Result<Option<PathBuf>, XdgError> {
match env::var_os("XDG_RUNTIME_DIR") {
Some(v) => {
let p = PathBuf::from(v);
if p.is_absolute() { Ok(Some(p)) } else { Ok(None) }
}
None => Ok(None),
}
}
pub fn runtime_dir_or_fallback(&self) -> PathBuf {
match self.runtime_dir() {
Ok(Some(p)) => p,
_ => {
let fallback = self.cache_home().join("runtime");
eprintln!(
"xdge WARNING: $XDG_RUNTIME_DIR is not set. \
Falling back to '{}'. This directory may lack \
the security properties required by the XDG spec.",
fallback.display()
);
fallback
}
}
}
pub fn data_dirs(&self) -> Vec<PathBuf> {
resolve_dirs("XDG_DATA_DIRS", &["/usr/local/share", "/usr/share"])
}
pub fn config_dirs(&self) -> Vec<PathBuf> {
resolve_dirs("XDG_CONFIG_DIRS", &["/etc/xdg"])
}
pub fn data_search_dirs(&self) -> Vec<PathBuf> {
let mut dirs = vec![self.data_home()];
dirs.extend(self.data_dirs());
dirs
}
pub fn config_search_dirs(&self) -> Vec<PathBuf> {
let mut dirs = vec![self.config_home()];
dirs.extend(self.config_dirs());
dirs
}
pub fn find_data_file(&self, relative_path: impl AsRef<Path>) -> Option<PathBuf> {
find_in_dirs(self.data_search_dirs(), relative_path.as_ref())
}
pub fn find_config_file(&self, relative_path: impl AsRef<Path>) -> Option<PathBuf> {
find_in_dirs(self.config_search_dirs(), relative_path.as_ref())
}
pub fn find_all_data_files(&self, relative_path: impl AsRef<Path>) -> Vec<PathBuf> {
find_all_in_dirs(self.data_search_dirs(), relative_path.as_ref())
}
pub fn find_all_config_files(&self, relative_path: impl AsRef<Path>) -> Vec<PathBuf> {
find_all_in_dirs(self.config_search_dirs(), relative_path.as_ref())
}
pub fn create_data_home(&self) -> Result<PathBuf, XdgError> {
create_dir(self.data_home())
}
pub fn create_config_home(&self) -> Result<PathBuf, XdgError> {
create_dir(self.config_home())
}
pub fn create_state_home(&self) -> Result<PathBuf, XdgError> {
create_dir(self.state_home())
}
pub fn create_cache_home(&self) -> Result<PathBuf, XdgError> {
create_dir(self.cache_home())
}
pub fn create_runtime_dir(&self) -> Result<PathBuf, XdgError> {
create_dir(self.runtime_dir_or_fallback())
}
pub fn create_data_subdir(&self, subdir: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
create_dir(self.data_home().join(subdir))
}
pub fn create_config_subdir(&self, subdir: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
create_dir(self.config_home().join(subdir))
}
pub fn create_state_subdir(&self, subdir: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
create_dir(self.state_home().join(subdir))
}
pub fn create_cache_subdir(&self, subdir: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
create_dir(self.cache_home().join(subdir))
}
pub fn create_runtime_subdir(&self, subdir: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
create_dir(self.runtime_dir_or_fallback().join(subdir))
}
pub fn place_data_file(&self, relative_path: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
place_file(self.data_home(), relative_path.as_ref())
}
pub fn place_config_file(&self, relative_path: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
place_file(self.config_home(), relative_path.as_ref())
}
pub fn place_state_file(&self, relative_path: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
place_file(self.state_home(), relative_path.as_ref())
}
pub fn place_cache_file(&self, relative_path: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
place_file(self.cache_home(), relative_path.as_ref())
}
pub fn place_runtime_file(&self, relative_path: impl AsRef<Path>) -> Result<PathBuf, XdgError> {
place_file(self.runtime_dir_or_fallback(), relative_path.as_ref())
}
}
impl Default for XdgDirs {
fn default() -> Self { Self::new() }
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME").map(PathBuf::from).filter(|p| p.is_absolute())
}
fn resolve_single(var: &str, default: PathBuf) -> PathBuf {
match env::var_os(var) {
Some(v) if !v.is_empty() => {
let p = PathBuf::from(v);
if p.is_absolute() { p } else { default }
}
_ => default,
}
}
fn resolve_dirs(var: &str, defaults: &[&str]) -> Vec<PathBuf> {
match env::var_os(var) {
Some(v) if !v.is_empty() => {
let paths: Vec<PathBuf> = env::split_paths(&v)
.filter(|p| p.is_absolute())
.collect();
if paths.is_empty() {
defaults.iter().map(PathBuf::from).collect()
} else {
paths
}
}
_ => defaults.iter().map(PathBuf::from).collect(),
}
}
fn find_in_dirs(dirs: Vec<PathBuf>, relative: &Path) -> Option<PathBuf> {
dirs.into_iter().map(|d| d.join(relative)).find(|p| p.exists())
}
fn find_all_in_dirs(dirs: Vec<PathBuf>, relative: &Path) -> Vec<PathBuf> {
dirs.into_iter().map(|d| d.join(relative)).filter(|p| p.exists()).collect()
}
fn create_dir(dir: PathBuf) -> Result<PathBuf, XdgError> {
if !dir.exists() {
fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
}
}
Ok(dir)
}
fn place_file(base: PathBuf, relative: &Path) -> Result<PathBuf, XdgError> {
let full = base.join(relative);
if let Some(parent) = full.parent() {
create_dir(parent.to_path_buf())?;
}
Ok(full)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn fake(home: &str) -> XdgDirs {
XdgDirs::new_with_home(PathBuf::from(home))
}
#[test]
fn default_data_home() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_HOME");
assert_eq!(fake("/home/alice").data_home(), PathBuf::from("/home/alice/.local/share"));
}
#[test]
fn default_config_home() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_CONFIG_HOME");
assert_eq!(fake("/home/alice").config_home(), PathBuf::from("/home/alice/.config"));
}
#[test]
fn default_state_home() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_STATE_HOME");
assert_eq!(fake("/home/alice").state_home(), PathBuf::from("/home/alice/.local/state"));
}
#[test]
fn default_cache_home() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_CACHE_HOME");
assert_eq!(fake("/home/alice").cache_home(), PathBuf::from("/home/alice/.cache"));
}
#[test]
fn default_user_bin_dir() {
assert_eq!(fake("/home/alice").user_bin_dir(), PathBuf::from("/home/alice/.local/bin"));
}
#[test]
fn default_data_dirs() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_DIRS");
assert_eq!(
fake("/home/alice").data_dirs(),
vec![PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]
);
}
#[test]
fn default_config_dirs() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_CONFIG_DIRS");
assert_eq!(fake("/home/alice").config_dirs(), vec![PathBuf::from("/etc/xdg")]);
}
#[test]
fn override_data_home() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_DATA_HOME", "/opt/data");
let r = fake("/home/alice").data_home();
env::remove_var("XDG_DATA_HOME");
assert_eq!(r, PathBuf::from("/opt/data"));
}
#[test]
fn override_config_dirs() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_CONFIG_DIRS", "/opt/cfg:/srv/cfg");
let r = fake("/home/alice").config_dirs();
env::remove_var("XDG_CONFIG_DIRS");
assert_eq!(r, vec![PathBuf::from("/opt/cfg"), PathBuf::from("/srv/cfg")]);
}
#[test]
fn relative_path_in_var_ignored() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_DATA_HOME", "relative/path");
let r = fake("/home/alice").data_home();
env::remove_var("XDG_DATA_HOME");
assert_eq!(r, PathBuf::from("/home/alice/.local/share"));
}
#[test]
fn relative_entries_in_list_filtered() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_DATA_DIRS", "/good:relative/bad:/also/good");
let r = fake("/home/alice").data_dirs();
env::remove_var("XDG_DATA_DIRS");
assert_eq!(r, vec![PathBuf::from("/good"), PathBuf::from("/also/good")]);
}
#[test]
fn all_relative_entries_falls_back_to_default() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_DATA_DIRS", "bad:also/bad");
let r = fake("/home/alice").data_dirs();
env::remove_var("XDG_DATA_DIRS");
assert_eq!(r, vec![PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]);
}
#[test]
fn search_dirs_order() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_HOME");
env::remove_var("XDG_DATA_DIRS");
let dirs = fake("/home/alice").data_search_dirs();
assert_eq!(dirs[0], PathBuf::from("/home/alice/.local/share"));
assert_eq!(dirs[1], PathBuf::from("/usr/local/share"));
}
#[test]
fn runtime_dir_when_set() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_RUNTIME_DIR", "/run/user/1000");
let r = fake("/home/alice").runtime_dir().unwrap();
env::remove_var("XDG_RUNTIME_DIR");
assert_eq!(r, Some(PathBuf::from("/run/user/1000")));
}
#[test]
fn runtime_dir_when_unset() {
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_RUNTIME_DIR");
assert_eq!(fake("/home/alice").runtime_dir().unwrap(), None);
}
#[test]
fn runtime_dir_relative_rejected() {
let _l = ENV_LOCK.lock().unwrap();
env::set_var("XDG_RUNTIME_DIR", "relative/runtime");
let r = fake("/home/alice").runtime_dir().unwrap();
env::remove_var("XDG_RUNTIME_DIR");
assert_eq!(r, None);
}
#[test]
fn create_data_home_creates_dir() {
let tmp = tempfile::tempdir().unwrap();
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_HOME");
let xdg = fake(tmp.path().to_str().unwrap());
assert!(xdg.create_data_home().unwrap().exists());
}
#[test]
fn create_subdir_creates_nested() {
let tmp = tempfile::tempdir().unwrap();
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_CONFIG_HOME");
let xdg = fake(tmp.path().to_str().unwrap());
assert!(xdg.create_config_subdir("myapp/v2").unwrap().exists());
}
#[test]
fn place_config_file_creates_parent() {
let tmp = tempfile::tempdir().unwrap();
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_CONFIG_HOME");
let xdg = fake(tmp.path().to_str().unwrap());
let path = xdg.place_config_file("myapp/settings.toml").unwrap();
assert!(path.parent().unwrap().exists());
assert_eq!(path.file_name().unwrap(), "settings.toml");
}
#[test]
fn find_data_file_returns_first_match() {
let tmp = tempfile::tempdir().unwrap();
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_HOME");
env::remove_var("XDG_DATA_DIRS");
let xdg = fake(tmp.path().to_str().unwrap());
let file_path = xdg.place_data_file("myapp/icon.png").unwrap();
std::fs::write(&file_path, b"fake").unwrap();
assert_eq!(xdg.find_data_file("myapp/icon.png").unwrap(), file_path);
}
#[test]
fn find_data_file_returns_none_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let _l = ENV_LOCK.lock().unwrap();
env::remove_var("XDG_DATA_HOME");
env::remove_var("XDG_DATA_DIRS");
let xdg = fake(tmp.path().to_str().unwrap());
assert!(xdg.find_data_file("does/not/exist.txt").is_none());
}
}