use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::paths::AppPathsProvider;
struct Installed {
name: String,
provider: std::sync::Arc<dyn AppPathsProvider>,
}
static INSTALLED: OnceLock<Installed> = OnceLock::new();
pub fn install(name: impl Into<String>, provider: std::sync::Arc<dyn AppPathsProvider>) {
let _ = INSTALLED.set(Installed {
name: name.into(),
provider,
});
}
fn scoped(base: impl Fn(&dyn AppPathsProvider) -> Option<PathBuf>) -> Option<PathBuf> {
let installed = INSTALLED.get()?;
Some(base(installed.provider.as_ref())?.join(&installed.name))
}
pub fn config() -> Option<PathBuf> {
scoped(|p| p.config_dir())
}
pub fn data() -> Option<PathBuf> {
scoped(|p| p.data_dir())
}
pub fn cache() -> Option<PathBuf> {
scoped(|p| p.cache_dir())
}
pub fn state() -> Option<PathBuf> {
scoped(|p| p.state_dir())
}
pub fn runtime() -> Option<PathBuf> {
scoped(|p| p.runtime_dir())
}
pub fn home() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
}
pub fn expand_tilde(path: &Path) -> PathBuf {
let Ok(rest) = path.strip_prefix("~") else {
return path.to_path_buf();
};
match home() {
Some(home) => home.join(rest),
None => path.to_path_buf(),
}
}
pub fn ensure_dir(dir: PathBuf) -> PathBuf {
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!("could not create {}: {e}", dir.display());
}
dir
}
pub fn user_dir(name: &str, fallback: &str) -> PathBuf {
let home = home();
let default = || match &home {
Some(home) => home.join(fallback),
None => PathBuf::from(fallback),
};
if let Some(value) = std::env::var_os(name).filter(|v| !v.is_empty()) {
return PathBuf::from(value);
}
let Some(home) = home.clone() else {
return default();
};
let Some(config_root) = INSTALLED
.get()
.and_then(|i| i.provider.config_dir())
.or_else(|| {
std::env::var_os("XDG_CONFIG_HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.or_else(|| Some(home.join(".config")))
})
else {
return default();
};
let Ok(text) = std::fs::read_to_string(config_root.join("user-dirs.dirs")) else {
return default();
};
parse_user_dirs(&text, name)
.map(|value| PathBuf::from(value.replace("$HOME", &home.to_string_lossy())))
.unwrap_or_else(default)
}
fn parse_user_dirs(text: &str, name: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim() != name {
continue;
}
let value = value.trim().trim_matches('"');
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
pub fn resolve_base(var: Option<OsString>, home: Option<OsString>, fallback: &str) -> PathBuf {
var.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| home.map(|h| PathBuf::from(h).join(fallback)))
.unwrap_or_else(|| PathBuf::from(fallback))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_leading_tilde_becomes_home_and_nothing_else_moves() {
let absolute = Path::new("/etc/hosts");
assert_eq!(expand_tilde(absolute), absolute);
let relative = Path::new("pictures/a.png");
assert_eq!(expand_tilde(relative), relative);
let inner = Path::new("/tmp/~/x");
assert_eq!(expand_tilde(inner), inner);
}
#[test]
fn a_user_dirs_assignment_is_read_past_its_quotes_and_comments() {
let text =
"# generated\nXDG_PICTURES_DIR=\"$HOME/Imágenes\"\nXDG_VIDEOS_DIR=\"$HOME/Vídeos\"\n";
assert_eq!(
parse_user_dirs(text, "XDG_PICTURES_DIR").as_deref(),
Some("$HOME/Imágenes")
);
assert_eq!(parse_user_dirs(text, "XDG_MUSIC_DIR"), None);
}
#[test]
fn a_commented_assignment_is_not_an_answer() {
let text = "#XDG_PICTURES_DIR=\"$HOME/wrong\"\nXDG_PICTURES_DIR=\"$HOME/right\"\n";
assert_eq!(
parse_user_dirs(text, "XDG_PICTURES_DIR").as_deref(),
Some("$HOME/right")
);
}
#[test]
fn the_xdg_rule_prefers_the_variable_then_home_then_the_bare_fallback() {
assert_eq!(
resolve_base(Some("/x".into()), Some("/home/u".into()), ".cache"),
PathBuf::from("/x")
);
assert_eq!(
resolve_base(Some("".into()), Some("/home/u".into()), ".cache"),
PathBuf::from("/home/u/.cache")
);
assert_eq!(resolve_base(None, None, ".cache"), PathBuf::from(".cache"));
}
#[test]
fn an_app_directory_is_none_until_a_runner_installs_one() {
if INSTALLED.get().is_none() {
assert_eq!(cache(), None);
assert_eq!(runtime(), None);
}
}
}