path-lib 1.0.0

Cross-platform application and OS directory resolution for Rust, with user and global scopes, hardened sub-path joining, and guarded filesystem operations. Zero dependencies.
//! The operating system, and its well-known folders.
//!
//! The OS is fixed at compile time, so [`os()`] is a constant: there is
//! nothing to detect at runtime and nothing to cache.
//!
//! Folders are resolved once per process, on first use, and cached in
//! `OnceLock` statics. A static lives for the life of the whole binary,
//! not just this crate, so every caller in the process shares one
//! resolution. These statics are the crate's only global state; they are
//! written once and read-only afterwards, which keeps them thread-safe
//! without locks on the read path.
//!
//! User folders and machine folders are cached separately. A process
//! started without a home directory (some service managers do this) can
//! still use machine-wide paths; only the user-scoped calls fail.

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};

/// An operating system family supported by this crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Os {
    /// Microsoft Windows.
    Windows,
    /// Apple macOS.
    MacOs,
    /// Linux.
    Linux,
    /// FreeBSD, OpenBSD, NetBSD, and other Unix-like systems that follow
    /// the XDG conventions.
    OtherUnix,
}

impl Os {
    /// A short lowercase name: `"windows"`, `"macos"`, `"linux"`, or
    /// `"unix"`.
    #[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())
    }
}

/// The operating system this binary was compiled for.
///
/// Evaluated at compile time; calling it costs nothing.
///
/// # Examples
///
/// ```
/// use path_lib::Os;
///
/// let os = path_lib::os();
/// if cfg!(windows) {
///     assert_eq!(os, Os::Windows);
/// }
/// ```
#[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])*
        ///
        /// # Errors
        ///
        /// [`Error::Unresolved`] when the current user's home directory
        /// cannot be determined.
        #[inline]
        pub fn $name() -> Result<&'static Path> {
            user_bases().map(|b| b.folders.$name.as_path())
        }
    };
}

user_folder!(
    /// The current user's home directory.
    ///
    /// `$HOME` on Unix; the profile folder (`C:\Users\<name>`) on Windows.
    ///
    /// # Examples
    ///
    /// ```
    /// let home = path_lib::home()?;
    /// assert!(home.is_absolute());
    /// # Ok::<(), path_lib::Error>(())
    /// ```
    home
);
user_folder!(
    /// The current user's Documents folder.
    ///
    /// Follows redirection: OneDrive-backed folders on Windows, and
    /// `user-dirs.dirs` (localised names such as `~/Dokumente`) on Linux.
    documents
);
user_folder!(
    /// The current user's Desktop folder. Follows redirection like
    /// [`documents`].
    desktop
);
user_folder!(
    /// The current user's Downloads folder. Follows redirection like
    /// [`documents`].
    downloads
);
user_folder!(
    /// The current user's Pictures folder. Follows redirection like
    /// [`documents`].
    pictures
);
user_folder!(
    /// The current user's Music folder. Follows redirection like
    /// [`documents`].
    music
);
user_folder!(
    /// The current user's Videos folder (`~/Movies` on macOS). Follows
    /// redirection like [`documents`].
    videos
);

/// Expands a leading `~` to the current user's home directory.
///
/// `~` alone becomes [`home`]; `~/rest` (or `~\rest` on Windows) becomes
/// `home/rest`, cleaned. Anything that does not start with `~` is returned
/// unchanged, so the function is safe to apply to every path read from a
/// configuration file or environment variable. Nothing else is expanded:
/// `$VARS` and `%VARS%` are left alone on purpose, because expanding them
/// would let configuration values pull in arbitrary environment state.
///
/// # Errors
///
/// - [`Error::InvalidPath`] with [`Reason::OtherUser`] for `~name`, which
///   would need another user's home directory.
/// - [`Error::Unresolved`] when the input starts with `~` and the home
///   directory cannot be determined.
///
/// # Examples
///
/// ```
/// let home = path_lib::home()?;
/// assert_eq!(path_lib::expand_home("~/data")?, home.join("data"));
/// assert_eq!(path_lib::expand_home("/var/lib/x")?, std::path::Path::new("/var/lib/x"));
/// assert!(path_lib::expand_home("~root/x").is_err());
/// # Ok::<(), path_lib::Error>(())
/// ```
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)),
    }
}

/// The root of the system volume: `/` on Unix, the system drive (usually
/// `C:\`) on Windows.
///
/// # Errors
///
/// [`Error::Unresolved`] on Windows when neither `%SystemDrive%` nor the
/// `ProgramData` Known Folder is available. Never fails on Unix.
///
/// # Examples
///
/// ```
/// let root = path_lib::root()?;
/// assert!(root.parent().is_none());
/// # Ok::<(), path_lib::Error>(())
/// ```
pub fn root() -> Result<&'static Path> {
    system_bases().map(|b| b.root.as_path())
}

/// The temporary directory for this process, as reported by
/// [`std::env::temp_dir`], cleaned.
///
/// This is the OS-level temp directory, which on Linux is usually the
/// shared `/tmp`. For an application's private scratch space use
/// [`App::temp`](crate::App::temp) instead.
#[must_use]
pub fn temp() -> &'static Path {
    TEMP.get_or_init(|| clean(std::env::temp_dir()))
}

/// The full path of the running executable.
///
/// # Errors
///
/// [`Error::Unresolved`] when the OS cannot report it (for example, a
/// Linux process without `/proc` mounted).
///
/// # Examples
///
/// ```
/// let exe = path_lib::exe()?;
/// assert!(exe.is_absolute());
/// # Ok::<(), path_lib::Error>(())
/// ```
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" })
}

/// The directory containing the running executable.
///
/// Useful for portable installs that keep their files next to the
/// binary. Pair it with [`App::portable`](crate::App::portable).
///
/// # Errors
///
/// Same as [`exe`].
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");
    }

    // Must match the `home` signature it stands in for.
    #[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]));
    }
}