whitaker-installer 0.2.7

Installer CLI for Whitaker Dylint lint libraries
Documentation
//! Directory resolution abstraction for platform-specific paths.
//!
//! This module provides a trait-based abstraction over directory resolution,
//! enabling dependency injection for testing. The [`BaseDirs`] trait defines
//! the interface for resolving platform-specific directories, while
//! [`SystemBaseDirs`] provides the real implementation using the
//! `directories-next` crate.
//!
//! # Testing
//!
//! In tests, use `MockBaseDirs` (generated by mockall) to avoid writing to
//! the user's actual home directory.

use std::path::PathBuf;

/// Returns the XDG bin home directory if set and absolute.
#[cfg(unix)]
fn xdg_bin_home() -> Option<PathBuf> {
    std::env::var_os("XDG_BIN_HOME")
        .map(PathBuf::from)
        .filter(|p| p.is_absolute())
}

/// Abstraction for resolving platform-specific base directories.
///
/// This trait enables dependency injection for directory resolution,
/// allowing tests to use mock implementations that don't touch the
/// real filesystem.
///
/// # Examples
///
/// ```no_run
/// use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs};
///
/// let dirs = SystemBaseDirs::new().expect("failed to initialize directories");
/// if let Some(bin_dir) = dirs.bin_dir() {
///     println!("Executables go in: {}", bin_dir.display());
/// }
/// ```
#[cfg_attr(test, mockall::automock)]
pub trait BaseDirs {
    /// Returns the user's home directory.
    ///
    /// - Unix: `$HOME` or `/home/<user>`
    /// - Windows: `%USERPROFILE%` or `C:\Users\<user>`
    fn home_dir(&self) -> Option<PathBuf>;

    /// Returns the directory for user executables.
    ///
    /// On Unix, respects `XDG_BIN_HOME` if set, otherwise falls back to
    /// `~/.local/bin`. On Windows, returns `~/.local/bin`. This follows the
    /// XDG Base Directory Specification convention.
    fn bin_dir(&self) -> Option<PathBuf>;

    /// Returns the directory for cloning the Whitaker repository.
    ///
    /// - Linux: `~/.local/share/whitaker`
    /// - macOS: `~/Library/Application Support/whitaker`
    /// - Windows: `%LOCALAPPDATA%\whitaker`
    fn whitaker_data_dir(&self) -> Option<PathBuf>;
}

/// Real implementation of [`BaseDirs`] using the `directories-next` crate.
///
/// This implementation resolves actual platform-specific directories
/// and should be used in production code. It wraps `directories_next::UserDirs`
/// and `directories_next::ProjectDirs` to provide consistent cross-platform
/// directory resolution.
///
/// # Examples
///
/// ```no_run
/// use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs};
///
/// let dirs = SystemBaseDirs::new().expect("failed to initialize directories");
/// let data_dir = dirs.whitaker_data_dir()
///     .expect("could not determine data directory");
/// println!("Whitaker data at: {}", data_dir.display());
/// ```
#[derive(Debug, Clone)]
pub struct SystemBaseDirs {
    user_dirs: directories_next::UserDirs,
    project_dirs: directories_next::ProjectDirs,
}

impl SystemBaseDirs {
    /// Creates a new `SystemBaseDirs` instance.
    ///
    /// Returns `None` if the platform's directory conventions cannot be
    /// determined (e.g., on unsupported platforms or when environment
    /// variables are not set correctly).
    #[must_use]
    pub fn new() -> Option<Self> {
        let user_dirs = directories_next::UserDirs::new()?;
        let project_dirs = directories_next::ProjectDirs::from("io", "github", "whitaker")?;
        Some(Self {
            user_dirs,
            project_dirs,
        })
    }
}

impl BaseDirs for SystemBaseDirs {
    fn home_dir(&self) -> Option<PathBuf> {
        Some(self.user_dirs.home_dir().to_owned())
    }

    fn bin_dir(&self) -> Option<PathBuf> {
        #[cfg(unix)]
        if let Some(path) = xdg_bin_home() {
            return Some(path);
        }
        self.home_dir().map(|h| h.join(".local").join("bin"))
    }

    fn whitaker_data_dir(&self) -> Option<PathBuf> {
        Some(self.project_dirs.data_dir().to_owned())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::env_test_guard;

    #[test]
    fn system_base_dirs_returns_some_on_supported_platforms() {
        let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");

        assert!(
            dirs.home_dir().is_some(),
            "expected home_dir to return Some"
        );
    }

    #[test]
    fn whitaker_data_dir_contains_whitaker() {
        let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");
        let data_dir = dirs.whitaker_data_dir();

        assert!(
            data_dir.is_some(),
            "expected whitaker_data_dir to return Some"
        );
        assert!(
            data_dir
                .as_ref()
                .is_some_and(|p| p.to_string_lossy().contains("whitaker")),
            "expected path to contain 'whitaker'"
        );
    }

    #[test]
    fn bin_dir_defaults_to_local_bin() {
        let _guard = env_test_guard();
        // Temporarily unset XDG_BIN_HOME to test the fallback
        temp_env::with_var_unset("XDG_BIN_HOME", || {
            let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");
            let bin_dir = dirs.bin_dir();

            assert!(bin_dir.is_some(), "expected bin_dir to return Some");
            assert!(
                bin_dir
                    .as_ref()
                    .is_some_and(|p| p.to_string_lossy().contains(".local")),
                "expected path to contain '.local'"
            );
        });
    }

    #[cfg(unix)]
    #[test]
    fn bin_dir_respects_xdg_bin_home() {
        let _guard = env_test_guard();
        temp_env::with_var("XDG_BIN_HOME", Some("/custom/bin"), || {
            let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");
            let bin_dir = dirs.bin_dir().expect("expected bin_dir to return Some");

            assert_eq!(bin_dir, PathBuf::from("/custom/bin"));
        });
    }

    #[cfg(unix)]
    #[test]
    fn bin_dir_ignores_relative_xdg_bin_home() {
        let _guard = env_test_guard();
        temp_env::with_var("XDG_BIN_HOME", Some("relative/path"), || {
            let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");
            let bin_dir = dirs.bin_dir().expect("expected bin_dir to return Some");

            // Should fall back to ~/.local/bin since XDG_BIN_HOME is relative
            assert!(
                bin_dir.to_string_lossy().contains(".local"),
                "expected path to contain '.local' when XDG_BIN_HOME is relative"
            );
        });
    }
}