pacsea 0.8.2

A fast, friendly TUI for browsing and installing Arch and AUR packages with built-in news and security scanning
Documentation
#[cfg(not(windows))]
use std::collections::HashSet;

#[cfg(not(windows))]
use super::OfficialPkg;
#[cfg(not(windows))]
use super::distro::{artix_repo_names, blackarch_repo_names, cachyos_repo_names, eos_repo_names};

/// What: Fetch a minimal list of official packages using `pacman -Sl`.
///
/// Inputs:
/// - None (calls `pacman -Sl` for known repositories in the background)
///
/// Output:
/// - `Ok(Vec<OfficialPkg>)` where `name`, `repo`, and `version` are set; `arch` and `description`
///   are empty for speed. The result is deduplicated by `(repo, name)`.
///
/// Details:
/// - Combines results from core, extra, multilib, `EndeavourOS`, `CachyOS`, `Artix Linux`, and
///   `BlackArch` repositories before sorting and deduplicating entries.
/// - Adds `pacman -Sl <name>` for each `[[repo]]` `name` in `repos.conf` that is not already in the
///   builtin list (e.g. Chaotic-AUR) so third-party databases appear in search.
#[cfg(not(windows))]
pub async fn fetch_official_pkg_names()
-> Result<Vec<OfficialPkg>, Box<dyn std::error::Error + Send + Sync>> {
    // 1) Get repo/name/version quickly via -Sl
    let core = tokio::task::spawn_blocking(|| crate::util::pacman::run_pacman(&["-Sl", "core"]))
        .await
        .ok()
        .and_then(Result::ok)
        .unwrap_or_default();
    let extra = tokio::task::spawn_blocking(|| crate::util::pacman::run_pacman(&["-Sl", "extra"]))
        .await
        .ok()
        .and_then(Result::ok)
        .unwrap_or_default();
    let multilib =
        tokio::task::spawn_blocking(|| crate::util::pacman::run_pacman(&["-Sl", "multilib"]))
            .await
            .ok()
            .and_then(Result::ok)
            .unwrap_or_default();
    // EOS/EndeavourOS: attempt both known names
    let mut eos_pairs: Vec<(&str, String)> = Vec::new();
    for &repo in eos_repo_names() {
        let body =
            tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(&["-Sl", repo]))
                .await
                .ok()
                .and_then(Result::ok)
                .unwrap_or_default();
        eos_pairs.push((repo, body));
    }
    // CachyOS: attempt multiple potential repo names; missing ones yield empty output
    let mut cach_pairs: Vec<(&str, String)> = Vec::new();
    for &repo in cachyos_repo_names() {
        let body =
            tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(&["-Sl", repo]))
                .await
                .ok()
                .and_then(Result::ok)
                .unwrap_or_default();
        cach_pairs.push((repo, body));
    }
    // Artix Linux: attempt all known Artix repo names; missing ones yield empty output
    let mut artix_pairs: Vec<(&str, String)> = Vec::new();
    for &repo in artix_repo_names() {
        let body =
            tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(&["-Sl", repo]))
                .await
                .ok()
                .and_then(Result::ok)
                .unwrap_or_default();
        artix_pairs.push((repo, body));
    }
    // BlackArch: attempt known repo names; missing ones yield empty output
    let mut blackarch_pairs: Vec<(&str, String)> = Vec::new();
    for &repo in blackarch_repo_names() {
        let body =
            tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(&["-Sl", repo]))
                .await
                .ok()
                .and_then(Result::ok)
                .unwrap_or_default();
        blackarch_pairs.push((repo, body));
    }

    let mut sl_lower_done = HashSet::<String>::new();
    for x in ["core", "extra", "multilib"] {
        sl_lower_done.insert(x.to_string());
    }
    for &r in eos_repo_names() {
        sl_lower_done.insert(r.to_lowercase());
    }
    for &r in cachyos_repo_names() {
        sl_lower_done.insert(r.to_lowercase());
    }
    for &r in artix_repo_names() {
        sl_lower_done.insert(r.to_lowercase());
    }
    for &r in blackarch_repo_names() {
        sl_lower_done.insert(r.to_lowercase());
    }
    let extra_repo_names = crate::logic::repos::repos_conf_repo_names_for_index_sl(&sl_lower_done);
    let mut repos_conf_pairs: Vec<(String, String)> = Vec::with_capacity(extra_repo_names.len());
    for name in extra_repo_names {
        let nm = name.clone();
        let body =
            tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(&["-Sl", &nm]))
                .await
                .ok()
                .and_then(Result::ok)
                .unwrap_or_default();
        repos_conf_pairs.push((name, body));
    }

    let mut pkgs: Vec<OfficialPkg> = Vec::new();
    for (repo, text) in [("core", core), ("extra", extra), ("multilib", multilib)]
        .into_iter()
        .chain(eos_pairs.into_iter())
        .chain(cach_pairs.into_iter())
        .chain(artix_pairs.into_iter())
        .chain(blackarch_pairs.into_iter())
    {
        for line in text.lines() {
            // Format: "repo pkgname version [installed]"
            let mut it = line.split_whitespace();
            let r = it.next();
            let n = it.next();
            let v = it.next();
            let Some(r) = r else {
                continue;
            };
            let Some(n) = n else {
                continue;
            };
            if r != repo {
                continue;
            }
            // Keep name, repo, version; leave arch/description empty for speed
            pkgs.push(OfficialPkg {
                name: n.to_string(),
                repo: r.to_string(),
                arch: String::new(),
                version: v.unwrap_or("").to_string(),
                description: String::new(),
            });
        }
    }
    for (repo, text) in repos_conf_pairs {
        for line in text.lines() {
            let mut it = line.split_whitespace();
            let r = it.next();
            let n = it.next();
            let v = it.next();
            let Some(r) = r else {
                continue;
            };
            let Some(n) = n else {
                continue;
            };
            if r != repo.as_str() {
                continue;
            }
            pkgs.push(OfficialPkg {
                name: n.to_string(),
                repo: r.to_string(),
                arch: String::new(),
                version: v.unwrap_or("").to_string(),
                description: String::new(),
            });
        }
    }
    // de-dup by (repo,name)
    pkgs.sort_by(|a, b| a.repo.cmp(&b.repo).then(a.name.cmp(&b.name)));
    pkgs.dedup_by(|a, b| a.repo == b.repo && a.name == b.name);

    // Do not enrich here; keep only fast fields for the initial on-disk index.
    Ok(pkgs)
}

#[cfg(all(test, not(target_os = "windows")))]
mod tests {
    #[tokio::test]
    #[allow(clippy::await_holding_lock)]
    /// What: Ensure `-Sl` output is parsed and deduplicated by `(repo, name)`.
    ///
    /// Inputs:
    /// - Fake `pacman` binary returning scripted `-Sl` responses for repos.
    ///
    /// Output:
    /// - `fetch_official_pkg_names` yields distinct package tuples in sorted order.
    ///
    /// Details:
    /// - Validates that cross-repo lines are filtered and duplicates removed before returning.
    async fn fetch_parses_sl_and_dedups_by_repo_and_name() {
        let _guard = crate::global_test_mutex_lock();

        // Create a fake pacman on PATH
        let old_path = std::env::var("PATH").unwrap_or_default();
        let mut root = std::env::temp_dir();
        root.push(format!(
            "pacsea_fake_pacman_sl_{}_{}",
            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 script = bin.clone();
        script.push("pacman");
        let body = r#"#!/usr/bin/env bash
set -e
if [[ "$1" == "-Sl" ]]; then
  repo="$2"
  case "$repo" in
    core)
      echo "core foo 1.0"
      echo "core foo 1.0"  # duplicate
      echo "extra should_not_be_kept 9.9" # different repo, filtered out
      ;;
    extra)
      echo "extra foo 1.1"
      echo "extra baz 3.0"
      ;;
    *) ;;
  esac
  exit 0
fi
exit 0
"#;
        std::fs::write(&script, body).expect("Failed to write test pacman script");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perm = std::fs::metadata(&script)
                .expect("Failed to read test pacman script metadata")
                .permissions();
            perm.set_mode(0o755);
            std::fs::set_permissions(&script, perm)
                .expect("Failed to set test pacman script permissions");
        }
        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
        unsafe { std::env::set_var("PATH", &new_path) };

        let pkgs = super::fetch_official_pkg_names()
            .await
            .expect("Failed to fetch official package names in test");

        // Cleanup PATH and temp files early
        unsafe { std::env::set_var("PATH", &old_path) };
        let _ = std::fs::remove_dir_all(&root);

        // Expect: (core,foo 1.0), (extra,foo 1.1), (extra,baz 3.0)
        assert_eq!(pkgs.len(), 3);
        let mut tuples: Vec<(String, String, String)> = pkgs
            .into_iter()
            .map(|p| (p.repo, p.name, p.version))
            .collect();
        tuples.sort();
        assert_eq!(
            tuples,
            vec![
                ("core".to_string(), "foo".to_string(), "1.0".to_string()),
                ("extra".to_string(), "baz".to_string(), "3.0".to_string()),
                ("extra".to_string(), "foo".to_string(), "1.1".to_string()),
            ]
        );
    }
}