use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::error::{Error, Reason, Result};
use crate::join::clean;
use crate::platform::{self, SystemBases, UserBases};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Os {
Windows,
MacOs,
Linux,
OtherUnix,
}
impl Os {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Windows => "windows",
Self::MacOs => "macos",
Self::Linux => "linux",
Self::OtherUnix => "unix",
}
}
}
impl std::fmt::Display for Os {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[must_use]
#[inline]
pub const fn os() -> Os {
#[cfg(windows)]
{
Os::Windows
}
#[cfg(target_os = "macos")]
{
Os::MacOs
}
#[cfg(target_os = "linux")]
{
Os::Linux
}
#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))]
{
Os::OtherUnix
}
}
static USER: OnceLock<std::result::Result<UserBases, &'static str>> = OnceLock::new();
static SYSTEM: OnceLock<std::result::Result<SystemBases, &'static str>> = OnceLock::new();
static TEMP: OnceLock<PathBuf> = OnceLock::new();
static EXE: OnceLock<Option<PathBuf>> = OnceLock::new();
pub(crate) fn user_bases() -> Result<&'static UserBases> {
USER.get_or_init(|| UserBases::resolve(&platform::process_env))
.as_ref()
.map_err(|dir| Error::Unresolved { dir })
}
pub(crate) fn system_bases() -> Result<&'static SystemBases> {
SYSTEM
.get_or_init(|| SystemBases::resolve(&platform::process_env))
.as_ref()
.map_err(|dir| Error::Unresolved { dir })
}
macro_rules! user_folder {
($(#[$doc:meta])* $name:ident) => {
$(#[$doc])*
#[inline]
pub fn $name() -> Result<&'static Path> {
user_bases().map(|b| b.folders.$name.as_path())
}
};
}
user_folder!(
home
);
user_folder!(
documents
);
user_folder!(
desktop
);
user_folder!(
downloads
);
user_folder!(
pictures
);
user_folder!(
music
);
user_folder!(
videos
);
pub fn expand_home(input: impl AsRef<str>) -> Result<PathBuf> {
expand_home_with(input.as_ref(), home)
}
fn expand_home_with(input: &str, home: impl FnOnce() -> Result<&'static Path>) -> Result<PathBuf> {
let Some(rest) = input.strip_prefix('~') else {
return Ok(PathBuf::from(input));
};
match rest.chars().next() {
None => Ok(home()?.to_path_buf()),
Some(c) if std::path::is_separator(c) => {
let rest = rest.trim_start_matches(std::path::is_separator);
Ok(clean(home()?.join(rest)))
}
Some(_) => Err(Error::invalid_path(input, Reason::OtherUser)),
}
}
pub fn root() -> Result<&'static Path> {
system_bases().map(|b| b.root.as_path())
}
#[must_use]
pub fn temp() -> &'static Path {
TEMP.get_or_init(|| clean(std::env::temp_dir()))
}
pub fn exe() -> Result<&'static Path> {
EXE.get_or_init(|| std::env::current_exe().ok().map(clean))
.as_deref()
.ok_or(Error::Unresolved { dir: "executable" })
}
pub fn exe_dir() -> Result<&'static Path> {
exe()?
.parent()
.ok_or(Error::Unresolved { dir: "executable" })
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_os_matches_target() {
let os = os();
assert_eq!(cfg!(windows), os == Os::Windows);
assert_eq!(cfg!(target_os = "macos"), os == Os::MacOs);
assert_eq!(cfg!(target_os = "linux"), os == Os::Linux);
assert!(!os.name().is_empty());
assert_eq!(os.to_string(), os.name());
}
#[test]
fn test_user_folders_are_absolute_and_cached() {
let first = home().unwrap();
let second = home().unwrap();
assert!(first.is_absolute());
assert!(
std::ptr::eq(first, second),
"home() must return the cached value"
);
for f in [documents, desktop, downloads, pictures, music, videos] {
assert!(f().unwrap().is_absolute());
}
}
#[test]
fn test_os_names_for_every_variant() {
assert_eq!(Os::Windows.name(), "windows");
assert_eq!(Os::MacOs.name(), "macos");
assert_eq!(Os::Linux.name(), "linux");
assert_eq!(Os::OtherUnix.name(), "unix");
}
#[allow(clippy::unnecessary_wraps)]
fn fake_home() -> Result<&'static Path> {
Ok(Path::new(if cfg!(windows) {
r"C:\Users\u"
} else {
"/home/u"
}))
}
#[test]
fn test_expand_home_forms() {
let h = fake_home().unwrap();
assert_eq!(expand_home_with("~", fake_home).unwrap(), h);
assert_eq!(
expand_home_with("~/a/b", fake_home).unwrap(),
h.join("a").join("b")
);
assert_eq!(
expand_home_with("~//a/./b/", fake_home).unwrap(),
h.join("a").join("b")
);
assert_eq!(
expand_home_with("rel/x", fake_home).unwrap(),
PathBuf::from("rel/x")
);
assert_eq!(expand_home_with("", fake_home).unwrap(), PathBuf::new());
assert_eq!(
expand_home_with("a~b", fake_home).unwrap(),
PathBuf::from("a~b")
);
}
#[cfg(windows)]
#[test]
fn test_expand_home_accepts_backslash_on_windows() {
let h = fake_home().unwrap();
assert_eq!(expand_home_with(r"~\a", fake_home).unwrap(), h.join("a"));
}
#[test]
fn test_expand_home_rejects_other_users_and_reports_missing_home() {
let err = expand_home_with("~root/x", fake_home).unwrap_err();
assert_eq!(err.reason(), Some(Reason::OtherUser));
let missing = || Err(Error::Unresolved { dir: "home" });
assert!(matches!(
expand_home_with("~/x", missing),
Err(Error::Unresolved { .. })
));
assert!(
expand_home_with("/abs", missing).is_ok(),
"home only needed for ~"
);
}
#[test]
fn test_root_has_no_parent() {
let r = root().unwrap();
assert!(r.is_absolute());
assert!(r.parent().is_none());
}
#[test]
fn test_temp_and_exe_are_absolute() {
assert!(temp().is_absolute());
assert!(exe().unwrap().is_absolute());
assert!(exe_dir().unwrap().is_dir());
}
#[test]
fn test_os_queries_are_thread_safe() {
let handles: Vec<_> = (0..8)
.map(|_| std::thread::spawn(|| home().map(Path::to_path_buf).ok()))
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(results.windows(2).all(|w| w[0] == w[1]));
}
}