pacsea 0.8.2

A fast, friendly TUI for browsing and installing Arch and AUR packages with built-in news and security scanning
Documentation
//! PKGBUILD fetching with rate limiting and caching.

use crate::logic::files::get_pkgbuild_from_cache;
use crate::state::{PackageItem, Source};
use crate::util::percent_encode;
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// Result type alias for PKGBUILD fetching operations.
type Result<T> = super::Result<T>;

/// Rate limiter for PKGBUILD requests to avoid overwhelming AUR servers.
///
/// Tracks the timestamp of the last PKGBUILD request to enforce minimum intervals.
static PKGBUILD_RATE_LIMITER: Mutex<Option<Instant>> = Mutex::new(None);
/// Minimum interval between PKGBUILD requests in milliseconds.
///
/// Reduced from 500ms to 200ms for faster preview operations.
const PKGBUILD_MIN_INTERVAL_MS: u64 = 200;
/// Extra curl flags for PKGBUILD HTTP fetches (override base connect timeout; cap total time).
const PKGBUILD_CURL_EXTRA: &[&str] = &["--connect-timeout", "8", "--max-time", "10"];

/// What: Fetch PKGBUILD content for a package from AUR or official Git packaging repos.
///
/// Inputs:
/// - `item`: Package whose PKGBUILD should be retrieved.
///
/// Output:
/// - `Ok(String)` with PKGBUILD text when available; `Err` on network or lookup failure.
///
/// # Errors
/// - Returns `Err` when network request fails (curl execution error)
/// - Returns `Err` when PKGBUILD cannot be fetched from AUR or official GitLab repositories
/// - Returns `Err` when rate limiting mutex is poisoned
/// - Returns `Err` when task spawn fails
///
/// # Panics
/// - Panics if the rate limiting mutex is poisoned
///
/// Details:
/// - First tries offline methods (yay/paru cache) for fast loading.
/// - Then tries network with rate limiting and timeout (10s).
/// - Uses curl with timeout to prevent hanging on slow servers.
/// - Passes a short `--connect-timeout` after the base `curl_args` flags so it overrides the
///   default 30s connect wait (important when official GitLab is unreachable).
pub async fn fetch_pkgbuild_fast(item: &PackageItem) -> Result<String> {
    let name = item.name.clone();

    // 1. Try offline methods first (yay/paru cache) - this is fast!
    if let Some(cached) = tokio::task::spawn_blocking({
        let name = name.clone();
        move || get_pkgbuild_from_cache(&name)
    })
    .await?
    {
        tracing::debug!("Using cached PKGBUILD for {} (offline)", name);
        return Ok(cached);
    }

    // 2. Rate limiting: ensure minimum interval between requests
    let delay = {
        let mut last_request = PKGBUILD_RATE_LIMITER
            .lock()
            .expect("PKGBUILD rate limiter mutex poisoned");
        if let Some(last) = *last_request {
            let elapsed = last.elapsed();
            if elapsed < Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS) {
                let delay = Duration::from_millis(PKGBUILD_MIN_INTERVAL_MS)
                    .checked_sub(elapsed)
                    .expect("elapsed should be less than PKGBUILD_MIN_INTERVAL_MS");
                tracing::debug!(
                    "Rate limiting PKGBUILD request for {}: waiting {:?}",
                    name,
                    delay
                );
                // Drop the guard before await
                *last_request = Some(Instant::now());
                Some(delay)
            } else {
                *last_request = Some(Instant::now());
                None
            }
        } else {
            *last_request = Some(Instant::now());
            None
        }
    };
    if let Some(delay) = delay {
        tokio::time::sleep(delay).await;
    }

    // 3. Fetch from network with timeout
    match &item.source {
        Source::Aur => {
            let url = format!(
                "https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
                percent_encode(&name)
            );
            // Use curl with timeout to prevent hanging
            let res = tokio::task::spawn_blocking({
                let url = url.clone();
                move || crate::util::curl::curl_text_with_args(&url, PKGBUILD_CURL_EXTRA)
            })
            .await??;
            Ok(res)
        }
        Source::Official { .. } => {
            let url_main = format!(
                "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/main/PKGBUILD",
                percent_encode(&name)
            );
            let main_result = tokio::task::spawn_blocking({
                let u = url_main.clone();
                move || crate::util::curl::curl_text_with_args(&u, PKGBUILD_CURL_EXTRA)
            })
            .await;
            if let Ok(Ok(txt)) = main_result {
                return Ok(txt);
            }
            let url_master = format!(
                "https://gitlab.archlinux.org/archlinux/packaging/packages/{}/-/raw/master/PKGBUILD",
                percent_encode(&name)
            );
            let txt = tokio::task::spawn_blocking({
                let u = url_master;
                move || crate::util::curl::curl_text_with_args(&u, PKGBUILD_CURL_EXTRA)
            })
            .await??;
            Ok(txt)
        }
    }
}

#[cfg(not(target_os = "windows"))]
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    #[ignore = "Only run when explicitly mentioned"]
    #[allow(clippy::await_holding_lock)]
    async fn pkgbuild_fetches_aur_via_curl_text() {
        let _guard = crate::sources::test_mutex()
            .lock()
            .expect("Test mutex poisoned");
        // Shim PATH with fake curl
        let old_path = std::env::var("PATH").unwrap_or_default();
        let mut root = std::env::temp_dir();
        root.push(format!(
            "pacsea_fake_curl_pkgbuild_{}_{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("System time is before UNIX epoch")
                .as_nanos()
        ));
        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
        let mut bin = root.clone();
        bin.push("bin");
        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
        let mut curl = bin.clone();
        curl.push("curl");
        let script = "#!/bin/sh\necho 'pkgver=1'\n";
        std::fs::write(&curl, script.as_bytes()).expect("Failed to write test curl script");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&curl)
                .expect("Failed to read test curl script metadata")
                .permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&curl, perm)
                .expect("Failed to set test curl script permissions");
        }
        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
        unsafe { std::env::set_var("PATH", &new_path) };

        let item = PackageItem {
            name: "yay-bin".into(),
            version: String::new(),
            description: String::new(),
            source: Source::Aur,
            popularity: None,
            out_of_date: None,
            orphaned: false,
        };
        let txt = super::fetch_pkgbuild_fast(&item)
            .await
            .expect("Failed to fetch PKGBUILD in test");
        assert!(txt.contains("pkgver=1"));

        unsafe { std::env::set_var("PATH", &old_path) };
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    #[allow(clippy::await_holding_lock)]
    fn pkgbuild_fetches_official_main_then_master() {
        let _guard = crate::global_test_mutex_lock();
        let old_path = std::env::var("PATH").unwrap_or_default();
        let mut root = std::env::temp_dir();
        root.push(format!(
            "pacsea_fake_curl_pkgbuild_official_{}_{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("System time is before UNIX epoch")
                .as_nanos()
        ));
        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
        let mut bin = root.clone();
        bin.push("bin");
        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
        let mut curl = bin.clone();
        curl.push("curl");
        // Fail when URL contains '/-/raw/main/' and succeed when '/-/raw/master/'
        // curl_args creates defaults like:
        // ["-sSLf", "--connect-timeout", "30", "--max-time", "90", "--max-filesize", "10485760", ...]
        // Get the last argument by looping through all arguments
        // Use printf instead of echo to avoid trailing newline that confuses the HTTP header parser
        let script = "#!/bin/sh\nfor arg; do :; done\nurl=\"$arg\"\nif echo \"$url\" | grep -q '/-/raw/main/'; then exit 22; fi\nprintf 'pkgrel=2'\n";
        std::fs::write(&curl, script.as_bytes()).expect("Failed to write test curl script");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&curl)
                .expect("Failed to read test curl script metadata")
                .permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&curl, perm)
                .expect("Failed to set test curl script permissions");
        }

        // Create fake paru and yay that fail (to prevent get_pkgbuild_from_cache from fetching real data)
        let mut paru = bin.clone();
        paru.push("paru");
        std::fs::write(&paru, b"#!/bin/sh\nexit 1\n").expect("Failed to write test paru script");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&paru)
                .expect("Failed to read test paru script metadata")
                .permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&paru, perm)
                .expect("Failed to set test paru script permissions");
        }

        let mut yay = bin.clone();
        yay.push("yay");
        std::fs::write(&yay, b"#!/bin/sh\nexit 1\n").expect("Failed to write test yay script");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&yay)
                .expect("Failed to read test yay script metadata")
                .permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&yay, perm)
                .expect("Failed to set test yay script permissions");
        }
        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
        unsafe { std::env::set_var("PATH", &new_path) };
        // Enable curl PATH lookup override so our fake curl is used instead of /usr/bin/curl
        unsafe { std::env::set_var("PACSEA_CURL_PATH", "1") };

        // Set HOME to empty directory to avoid finding cached PKGBUILDs
        let old_home = std::env::var("HOME").unwrap_or_default();
        unsafe { std::env::set_var("HOME", root.to_string_lossy().as_ref()) };

        // Create a new tokio runtime AFTER setting PATH and HOME so worker threads inherit them
        let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime for test");
        let txt = rt.block_on(async {
            let item = PackageItem {
                name: "ripgrep".into(),
                version: String::new(),
                description: String::new(),
                source: Source::Official {
                    repo: "extra".into(),
                    arch: "x86_64".into(),
                },
                popularity: None,
                out_of_date: None,
                orphaned: false,
            };
            super::fetch_pkgbuild_fast(&item)
                .await
                .expect("Failed to fetch PKGBUILD in test")
        });

        assert!(txt.contains("pkgrel=2"));

        unsafe { std::env::set_var("PATH", &old_path) };
        unsafe { std::env::set_var("HOME", &old_home) };
        unsafe { std::env::remove_var("PACSEA_CURL_PATH") };
        let _ = std::fs::remove_dir_all(&root);
    }
}