use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub const ROOT_ENV: &str = "ASHLAR_DIRS_ROOT";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AppDirs {
pub config: PathBuf,
pub data: PathBuf,
pub cache: PathBuf,
pub logs: PathBuf,
}
impl AppDirs {
pub fn new(qualifier: &str, org: &str, app: &str) -> Option<AppDirs> {
match std::env::var_os(ROOT_ENV) {
Some(root) if !root.is_empty() => Some(AppDirs::under(root)),
_ => platform(qualifier, org, app),
}
}
pub fn under(root: impl AsRef<Path>) -> AppDirs {
let root = root.as_ref();
AppDirs { config: root.join("config"), data: root.join("data"), cache: root.join("cache"), logs: root.join("logs") }
}
pub fn data_only(app: &str, override_env: &str) -> Option<PathBuf> {
data_dir_for(std::env::consts::OS, app, override_env, |k| std::env::var_os(k))
}
pub fn ensure(&self) -> std::io::Result<()> {
for d in [&self.config, &self.data, &self.cache, &self.logs] {
std::fs::create_dir_all(d)?;
}
Ok(())
}
}
pub fn data_dir_for(os: &str, app: &str, override_env: &str, var: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let env = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);
if let Some(d) = env(override_env) {
return Some(d);
}
match os {
"macos" => env("HOME").map(|h| h.join("Library/Application Support").join(app)),
"windows" => env("APPDATA").map(|d| d.join(app)),
_ => {
let app = app.to_lowercase();
env("XDG_DATA_HOME").map(|d| d.join(&app)).or_else(|| env("HOME").map(|h| h.join(".local/share").join(&app)))
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn platform(qualifier: &str, org: &str, app: &str) -> Option<AppDirs> {
let p = directories::ProjectDirs::from(qualifier, org, app)?;
let logs = logs_dir(&p)?;
Some(AppDirs { config: p.config_dir().to_path_buf(), data: p.data_dir().to_path_buf(), cache: p.cache_dir().to_path_buf(), logs })
}
#[cfg(target_arch = "wasm32")]
fn platform(_qualifier: &str, _org: &str, _app: &str) -> Option<AppDirs> {
None
}
#[cfg(target_os = "macos")]
fn logs_dir(p: &directories::ProjectDirs) -> Option<PathBuf> {
let home = directories::BaseDirs::new()?.home_dir().to_path_buf();
Some(home.join("Library/Logs").join(p.project_path()))
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "macos")))]
fn logs_dir(p: &directories::ProjectDirs) -> Option<PathBuf> {
Some(p.state_dir().unwrap_or_else(|| p.data_local_dir()).join("logs"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn under_root() {
let d = AppDirs::under("/r");
assert_eq!(d.config, Path::new("/r/config"));
assert_eq!(d.logs, Path::new("/r/logs"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn platform_dirs_are_distinct_and_named_after_the_app() {
let Some(d) = platform("com", "Example Org", "Demo App") else { return };
for p in [&d.cache, &d.logs] {
let letters: String = p.to_string_lossy().chars().filter(char::is_ascii_alphanumeric).collect();
assert!(letters.to_ascii_lowercase().contains("demoapp"), "{p:?}");
}
assert_ne!(d.cache, d.data);
assert_ne!(d.logs, d.data);
}
#[test]
fn flat_data_dir_paths_are_pinned() {
let dir = |os: &str, vars: &[(&str, &str)]| {
let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
data_dir_for(os, "Demo", "DEMO_DATA_DIR", |k| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.into()))
};
let home = [("HOME", "/home/u"), ("APPDATA", "C:/Users/u/AppData/Roaming"), ("XDG_DATA_HOME", "/xdg")];
assert_eq!(dir("macos", &home), Some(PathBuf::from("/home/u/Library/Application Support/Demo")));
assert_eq!(dir("windows", &home), Some(PathBuf::from("C:/Users/u/AppData/Roaming").join("Demo")));
assert_eq!(dir("linux", &home), Some(PathBuf::from("/xdg/demo")));
assert_eq!(dir("freebsd", &[("HOME", "/home/u")]), Some(PathBuf::from("/home/u/.local/share/demo")));
assert_eq!(dir("linux", &[("HOME", "/home/u"), ("XDG_DATA_HOME", "")]), Some(PathBuf::from("/home/u/.local/share/demo")));
assert_eq!(dir("linux", &[("XDG_DATA_HOME", "rel")]), Some(PathBuf::from("rel/demo")), "a relative XDG_DATA_HOME is used as is");
for os in ["macos", "windows", "linux"] {
assert_eq!(dir(os, &[]), None, "{os}: no home, no data directory");
let mut with_override = home.to_vec();
with_override.push(("DEMO_DATA_DIR", "/custom"));
assert_eq!(dir(os, &with_override), Some(PathBuf::from("/custom")), "{os}");
with_override.push((ROOT_ENV, "/elsewhere"));
assert_eq!(dir(os, &with_override), Some(PathBuf::from("/custom")), "{os}: the root override does not move it");
assert_eq!(dir(os, &[(ROOT_ENV, "/elsewhere")]), None, "{os}");
assert!(
dir(os, &[("DEMO_DATA_DIR", ""), ("HOME", "/h"), ("APPDATA", "/h"), ("XDG_DATA_HOME", "/h")]).is_some(),
"{os}: an empty override is unset"
);
}
assert_eq!(AppDirs::data_only("Demo", "DEMO_DATA_DIR"), data_dir_for(std::env::consts::OS, "Demo", "DEMO_DATA_DIR", |k| std::env::var_os(k)));
}
#[test]
fn ensure_creates_all() {
let root = tempfile::tempdir().unwrap();
let d = AppDirs::under(root.path());
d.ensure().unwrap();
assert!(d.config.is_dir() && d.data.is_dir() && d.cache.is_dir() && d.logs.is_dir());
}
}