pacsea 0.8.2

A fast, friendly TUI for browsing and installing Arch and AUR packages with built-in news and security scanning
Documentation
use std::env;
use std::path::{Path, PathBuf};

/// What: Locate the active theme configuration file, considering modern and legacy layouts.
///
/// Inputs:
/// - None (reads environment variables to build candidate paths).
///
/// Output:
/// - `Some(PathBuf)` pointing to the first readable theme file; `None` when nothing exists.
///
/// Details:
/// - Prefers `$HOME/.config/pacsea/theme.conf`, then legacy `pacsea.conf`, and repeats for XDG paths.
pub fn resolve_theme_config_path() -> Option<PathBuf> {
    let home = env::var("HOME").ok();
    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Some(h) = home.as_deref() {
        let base = Path::new(h).join(".config").join("pacsea");
        candidates.push(base.join("theme.conf"));
        candidates.push(base.join("pacsea.conf")); // legacy
    }
    if let Some(xdg) = xdg_config.as_deref() {
        let x = Path::new(xdg).join("pacsea");
        candidates.push(x.join("theme.conf"));
        candidates.push(x.join("pacsea.conf")); // legacy
    }
    candidates.into_iter().find(|p| p.is_file())
}

/// What: Locate the active settings configuration file, prioritizing the split layout.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `Some(PathBuf)` for the resolved settings file; `None` when no candidate exists.
///
/// Details:
/// - Searches `$HOME` and `XDG_CONFIG_HOME` for `settings.conf`, then falls back to `pacsea.conf`.
pub(super) fn resolve_settings_config_path() -> Option<PathBuf> {
    let home = env::var("HOME").ok();
    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Some(h) = home.as_deref() {
        let base = Path::new(h).join(".config").join("pacsea");
        candidates.push(base.join("settings.conf"));
        candidates.push(base.join("pacsea.conf")); // legacy
    }
    if let Some(xdg) = xdg_config.as_deref() {
        let x = Path::new(xdg).join("pacsea");
        candidates.push(x.join("settings.conf"));
        candidates.push(x.join("pacsea.conf")); // legacy
    }
    candidates.into_iter().find(|p| p.is_file())
}

/// What: Locate the keybindings configuration file for Pacsea.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `Some(PathBuf)` when a keybinds file is present; `None` otherwise.
///
/// Details:
/// - Checks both `$HOME/.config/pacsea/keybinds.conf` and the legacy `pacsea.conf`, mirrored for XDG.
pub(super) fn resolve_keybinds_config_path() -> Option<PathBuf> {
    let home = env::var("HOME").ok();
    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Some(h) = home.as_deref() {
        let base = Path::new(h).join(".config").join("pacsea");
        candidates.push(base.join("keybinds.conf"));
        candidates.push(base.join("pacsea.conf")); // legacy
    }
    if let Some(xdg) = xdg_config.as_deref() {
        let x = Path::new(xdg).join("pacsea");
        candidates.push(x.join("keybinds.conf"));
        candidates.push(x.join("pacsea.conf")); // legacy
    }
    candidates.into_iter().find(|p| p.is_file())
}

/// What: Locate the `repos.conf` file for third-party repository definitions (TOML).
///
/// Inputs:
/// - None (reads `HOME` / `XDG_CONFIG_HOME`).
///
/// Output:
/// - `Some(PathBuf)` when a candidate file exists; `None` otherwise.
///
/// Details:
/// - Checks `$HOME/.config/pacsea/repos.conf` then `XDG_CONFIG_HOME/pacsea/repos.conf`.
/// - Does not fall back to legacy `pacsea.conf` (repos live only in the split layout).
#[must_use]
pub fn resolve_repos_config_path() -> Option<PathBuf> {
    let home = env::var("HOME").ok();
    let xdg_config = env::var("XDG_CONFIG_HOME").ok();
    let mut candidates: Vec<PathBuf> = Vec::new();
    if let Some(h) = home.as_deref() {
        let base = Path::new(h).join(".config").join("pacsea");
        candidates.push(base.join("repos.conf"));
    }
    if let Some(xdg) = xdg_config.as_deref() {
        let x = Path::new(xdg).join("pacsea");
        candidates.push(x.join("repos.conf"));
    }
    candidates.into_iter().find(|p| p.is_file())
}

/// What: Resolve an XDG base directory, falling back to `$HOME` with provided segments.
///
/// Inputs:
/// - `var`: Environment variable name, e.g., `XDG_CONFIG_HOME`.
/// - `home_default`: Path segments appended to `$HOME` when the variable is unset.
///
/// Output:
/// - `PathBuf` pointing to the derived base directory.
///
/// Details:
/// - Treats empty environment values as unset and gracefully handles missing `$HOME`.
fn xdg_base_dir(var: &str, home_default: &[&str]) -> PathBuf {
    if let Ok(p) = env::var(var)
        && !p.trim().is_empty()
    {
        return PathBuf::from(p);
    }
    let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
    let mut base = PathBuf::from(home);
    for seg in home_default {
        base = base.join(seg);
    }
    base
}

/// What: Build `$HOME/.config/pacsea`, ensuring the directory exists when `$HOME` is set.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `Some(PathBuf)` when the directory is accessible; `None` if `$HOME` is missing or creation fails.
///
/// Details:
/// - Serves as the preferred base for other configuration directories.
/// - On Windows, also checks `APPDATA` and `USERPROFILE` if `HOME` is not set.
fn home_config_dir() -> Option<PathBuf> {
    // Try HOME first (works on Unix and Windows if set)
    if let Ok(home) = env::var("HOME") {
        let dir = Path::new(&home).join(".config").join("pacsea");
        if std::fs::create_dir_all(&dir).is_ok() {
            return Some(dir);
        }
    }
    // Windows fallback: use APPDATA or USERPROFILE
    #[cfg(windows)]
    {
        if let Ok(appdata) = env::var("APPDATA") {
            let dir = Path::new(&appdata).join("pacsea");
            if std::fs::create_dir_all(&dir).is_ok() {
                return Some(dir);
            }
        }
        if let Ok(userprofile) = env::var("USERPROFILE") {
            let dir = Path::new(&userprofile).join(".config").join("pacsea");
            if std::fs::create_dir_all(&dir).is_ok() {
                return Some(dir);
            }
        }
    }
    None
}

/// What: Resolve the Pacsea configuration directory, ensuring it exists on disk.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `PathBuf` pointing to the Pacsea config directory.
///
/// Details:
/// - Prefers `$HOME/.config/pacsea`, falling back to `XDG_CONFIG_HOME/pacsea` when necessary.
#[must_use]
pub fn config_dir() -> PathBuf {
    // Prefer HOME ~/.config/pacsea first
    if let Some(dir) = home_config_dir() {
        return dir;
    }
    // Fallback: use XDG_CONFIG_HOME (or default to ~/.config) and ensure
    let base = xdg_base_dir("XDG_CONFIG_HOME", &[".config"]);
    let dir = base.join("pacsea");
    let _ = std::fs::create_dir_all(&dir);
    dir
}

/// What: Obtain the logs subdirectory inside the Pacsea config folder.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `PathBuf` leading to the `logs` directory (created if missing).
///
/// Details:
/// - Builds upon `config_dir()` and ensures a stable location for log files.
#[must_use]
pub fn logs_dir() -> PathBuf {
    let base = config_dir();
    let dir = base.join("logs");
    let _ = std::fs::create_dir_all(&dir);
    dir
}

/// What: Obtain the lists subdirectory inside the Pacsea config folder.
///
/// Inputs:
/// - None.
///
/// Output:
/// - `PathBuf` leading to the `lists` directory (created if missing).
///
/// Details:
/// - Builds upon `config_dir()` and ensures storage for exported package lists.
#[must_use]
pub fn lists_dir() -> PathBuf {
    let base = config_dir();
    let dir = base.join("lists");
    let _ = std::fs::create_dir_all(&dir);
    dir
}

#[cfg(test)]
mod tests {
    /// What: Manage temporary HOME override for path resolution tests.
    ///
    /// Inputs:
    /// - `base`: Temporary HOME root directory.
    ///
    /// Output:
    /// - Guard that restores `HOME` and `XDG_CONFIG_HOME` and removes temp directory on drop.
    ///
    /// Details:
    /// - Clears `XDG_CONFIG_HOME` while active so `config_dir` and resolvers cannot use the
    ///   developer's real XDG config tree.
    /// - Provides panic-safe cleanup for tests mutating process-wide environment.
    struct HomeTestGuard {
        orig_home: Option<std::ffi::OsString>,
        orig_xdg: Option<std::ffi::OsString>,
        base: std::path::PathBuf,
    }

    impl HomeTestGuard {
        /// What: Create a HOME override guard for test isolation.
        ///
        /// Inputs:
        /// - `base`: Temporary path to use as `HOME`.
        ///
        /// Output:
        /// - Initialized `HomeTestGuard`.
        ///
        /// Details:
        /// - Captures original `HOME` and `XDG_CONFIG_HOME`, applies test `HOME`, and unsets XDG.
        fn new(base: std::path::PathBuf) -> Self {
            let orig_home = std::env::var_os("HOME");
            let orig_xdg = std::env::var_os("XDG_CONFIG_HOME");
            let _ = std::fs::create_dir_all(&base);
            unsafe {
                std::env::set_var("HOME", base.display().to_string());
                std::env::remove_var("XDG_CONFIG_HOME");
            }
            Self {
                orig_home,
                orig_xdg,
                base,
            }
        }
    }

    impl Drop for HomeTestGuard {
        fn drop(&mut self) {
            unsafe {
                if let Some(v) = self.orig_home.as_ref() {
                    std::env::set_var("HOME", v);
                } else {
                    std::env::remove_var("HOME");
                }
                if let Some(v) = self.orig_xdg.as_ref() {
                    std::env::set_var("XDG_CONFIG_HOME", v);
                } else {
                    std::env::remove_var("XDG_CONFIG_HOME");
                }
            }
            let _ = std::fs::remove_dir_all(&self.base);
        }
    }

    #[test]
    /// What: Verify path helpers resolve under the Pacsea config directory rooted at `HOME`.
    ///
    /// Inputs:
    /// - Temporary `HOME` directory substituted to capture generated paths.
    ///
    /// Output:
    /// - `config_dir`, `logs_dir`, and `lists_dir` end with `pacsea`, `logs`, and `lists` respectively.
    ///
    /// Details:
    /// - Restores the original `HOME` and `XDG_CONFIG_HOME` afterwards to avoid polluting the real
    ///   configuration tree.
    fn paths_config_lists_logs_under_home() {
        let _guard = crate::theme::test_mutex()
            .lock()
            .expect("Test mutex poisoned");
        let base = std::env::temp_dir().join(format!(
            "pacsea_test_paths_{}_{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("System time is before UNIX epoch")
                .as_nanos()
        ));
        let _home_guard = HomeTestGuard::new(base);
        let cfg = super::config_dir();
        let logs = super::logs_dir();
        let lists = super::lists_dir();
        assert!(cfg.ends_with("pacsea"));
        assert!(logs.ends_with("logs"));
        assert!(lists.ends_with("lists"));
    }

    #[test]
    /// What: Ensure `config_dir` stays under the test `HOME` when `XDG_CONFIG_HOME` was set in the environment.
    ///
    /// Inputs:
    /// - A bogus `XDG_CONFIG_HOME` set before `HomeTestGuard` (simulates a developer shell).
    ///
    /// Output:
    /// - `config_dir` is a path under the temporary home root.
    ///
    /// Details:
    /// - Guards against regressions where only `HOME` is overridden and the XDG fallback or other
    ///   helpers could still target the real config tree.
    fn paths_config_stays_under_temp_home_when_xdg_config_home_was_set() {
        let _guard = crate::theme::test_mutex()
            .lock()
            .expect("Test mutex poisoned");
        let base = std::env::temp_dir().join(format!(
            "pacsea_test_paths_xdg_{}_{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("System time is before UNIX epoch")
                .as_nanos()
        ));
        let home_root = base.clone();
        unsafe {
            std::env::set_var(
                "XDG_CONFIG_HOME",
                "/nonexistent/pacsea_test_xdg_decoy_must_not_be_used",
            );
        }
        let _home_guard = HomeTestGuard::new(base);
        let cfg = super::config_dir();
        assert!(
            cfg.starts_with(&home_root),
            "config_dir should resolve under test HOME, not decoy XDG_CONFIG_HOME; got {cfg:?}"
        );
    }
}