use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct XdgEnv {
pub(crate) xdg_cache_home: Option<OsString>,
pub(crate) xdg_config_home: Option<OsString>,
pub(crate) home: Option<OsString>,
}
impl XdgEnv {
#[must_use]
pub fn from_process_env() -> Self {
Self::from_os_options(
std::env::var_os("XDG_CACHE_HOME"),
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
#[must_use]
pub fn from_os_options(
xdg_cache_home: Option<OsString>,
xdg_config_home: Option<OsString>,
home: Option<OsString>,
) -> Self {
fn nonempty(v: Option<OsString>) -> Option<OsString> {
v.filter(|s| !s.is_empty())
}
Self {
xdg_cache_home: nonempty(xdg_cache_home),
xdg_config_home: nonempty(xdg_config_home),
home: nonempty(home),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum XdgScope {
Cache,
Config,
}
#[must_use]
pub fn resolve_subdir(env: &XdgEnv, scope: XdgScope, sub: &str) -> Option<PathBuf> {
let (xdg, home_sub) = match scope {
XdgScope::Cache => (env.xdg_cache_home.as_deref(), ".cache"),
XdgScope::Config => (env.xdg_config_home.as_deref(), ".config"),
};
if let Some(x) = xdg {
return Some(linesmith_subdir(PathBuf::from(x), sub));
}
env.home
.as_deref()
.map(|h| linesmith_subdir(PathBuf::from(h).join(home_sub), sub))
}
fn linesmith_subdir(base: PathBuf, sub: &str) -> PathBuf {
let with_app = base.join("linesmith");
if sub.is_empty() {
with_app
} else {
with_app.join(sub)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn os(s: &str) -> Option<OsString> {
Some(OsString::from(s))
}
#[test]
fn from_os_options_filters_empty_strings_to_none() {
let env = XdgEnv::from_os_options(Some(OsString::new()), os("/x"), Some(OsString::new()));
assert_eq!(env.xdg_cache_home, None);
assert_eq!(env.xdg_config_home, os("/x"));
assert_eq!(env.home, None);
}
#[test]
fn cache_scope_prefers_xdg_cache_home_over_home() {
let env = XdgEnv::from_os_options(os("/xdg"), None, os("/home"));
assert_eq!(
resolve_subdir(&env, XdgScope::Cache, ""),
Some(PathBuf::from("/xdg/linesmith"))
);
}
#[test]
fn cache_scope_falls_back_to_home_dot_cache() {
let env = XdgEnv::from_os_options(None, None, os("/home/user"));
assert_eq!(
resolve_subdir(&env, XdgScope::Cache, ""),
Some(PathBuf::from("/home/user/.cache/linesmith"))
);
}
#[test]
fn config_scope_uses_xdg_config_home() {
let env = XdgEnv::from_os_options(None, os("/conf"), None);
assert_eq!(
resolve_subdir(&env, XdgScope::Config, "segments"),
Some(PathBuf::from("/conf/linesmith/segments"))
);
}
#[test]
fn config_scope_falls_back_to_home_dot_config() {
let env = XdgEnv::from_os_options(None, None, os("/home/user"));
assert_eq!(
resolve_subdir(&env, XdgScope::Config, "themes"),
Some(PathBuf::from("/home/user/.config/linesmith/themes"))
);
}
#[test]
fn config_scope_does_not_borrow_xdg_cache_home() {
let env = XdgEnv::from_os_options(os("/xdg-cache"), None, None);
assert_eq!(resolve_subdir(&env, XdgScope::Config, ""), None);
}
#[test]
fn returns_none_when_neither_xdg_nor_home_is_set() {
let env = XdgEnv::default();
assert_eq!(resolve_subdir(&env, XdgScope::Cache, "x"), None);
assert_eq!(resolve_subdir(&env, XdgScope::Config, "y"), None);
}
#[test]
fn empty_sub_does_not_append_trailing_slash() {
let env = XdgEnv::from_os_options(os("/xdg"), None, None);
let path = resolve_subdir(&env, XdgScope::Cache, "").unwrap();
assert_eq!(path, PathBuf::from("/xdg/linesmith"));
assert_eq!(path.components().count(), 3); }
#[cfg(unix)]
#[test]
fn preserves_non_utf8_xdg_cache_home() {
use std::os::unix::ffi::OsStringExt;
let bytes = b"/srv/caf\xe9-bin".to_vec();
let xdg = OsString::from_vec(bytes.clone());
let env = XdgEnv::from_os_options(Some(xdg), None, os("/home/user"));
let resolved = resolve_subdir(&env, XdgScope::Cache, "").unwrap();
assert!(
resolved
.as_os_str()
.as_encoded_bytes()
.starts_with(b"/srv/caf\xe9-bin/linesmith"),
"expected non-UTF-8 XDG to be preserved: got {:?}",
resolved
);
}
}