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 crate::state::{PackageItem, Source};

use super::idx;

/// What: Search the official index for packages whose names match `query`.
///
/// Inputs:
/// - `query`: Raw query string
/// - `fuzzy`: When `true`, uses fuzzy matching (fzf-style); when `false`, uses substring matching
///
/// Output:
/// - Vector of `PackageItem`s populated from the index; enrichment is not performed here.
///   An empty or whitespace-only query returns an empty list.
///   When fuzzy mode is enabled, items are returned with scores for sorting.
///
/// Details:
/// - When `fuzzy` is `false`, performs a case-insensitive substring match on package names.
/// - When `fuzzy` is `true`, uses fuzzy matching and returns items with match scores.
#[must_use]
pub fn search_official(query: &str, fuzzy: bool) -> Vec<(PackageItem, Option<i64>)> {
    let ql = query.trim();
    if ql.is_empty() {
        return Vec::new();
    }
    let mut items = Vec::new();
    if let Ok(g) = idx().read() {
        // Create matcher once per search query for better performance
        let fuzzy_matcher = if fuzzy {
            Some(fuzzy_matcher::skim::SkimMatcherV2::default())
        } else {
            None
        };
        for p in &g.pkgs {
            let match_score = if fuzzy {
                fuzzy_matcher
                    .as_ref()
                    .and_then(|m| crate::util::fuzzy_match_rank_with_matcher(&p.name, ql, m))
            } else {
                let nl = p.name.to_lowercase();
                let ql_lower = ql.to_lowercase();
                if nl.contains(&ql_lower) {
                    Some(0) // Use 0 as placeholder score for substring matches
                } else {
                    None
                }
            };
            if let Some(score) = match_score {
                items.push((
                    PackageItem {
                        name: p.name.clone(),
                        version: p.version.clone(),
                        description: p.description.clone(),
                        source: Source::Official {
                            repo: p.repo.clone(),
                            arch: p.arch.clone(),
                        },
                        popularity: None,
                        out_of_date: None,
                        orphaned: false,
                    },
                    Some(score),
                ));
            }
        }
    }
    items
}

/// What: Return the entire official index as a list of `PackageItem`s.
///
/// Inputs:
/// - None
///
/// Output:
/// - Vector of all official items mapped to `PackageItem`.
///
/// Details:
/// - Clones data from the shared index under a read lock and omits popularity data.
#[must_use]
pub fn all_official() -> Vec<PackageItem> {
    let mut items = Vec::new();
    if let Ok(g) = idx().read() {
        items.reserve(g.pkgs.len());
        for p in &g.pkgs {
            items.push(PackageItem {
                name: p.name.clone(),
                version: p.version.clone(),
                description: p.description.clone(),
                source: Source::Official {
                    repo: p.repo.clone(),
                    arch: p.arch.clone(),
                },
                popularity: None,
                out_of_date: None,
                orphaned: false,
            });
        }
    }
    items
}

/// What: Return the entire official list; if empty, try to populate from disk and return it.
///
/// Inputs:
/// - `path`: Path to on-disk JSON index to load as a fallback
///
/// Output:
/// - Vector of `PackageItem`s representing the current in-memory (or loaded) index.
///
/// Details:
/// - Loads from disk only when the in-memory list is empty to avoid redundant IO.
#[must_use]
pub fn all_official_or_fetch(path: &std::path::Path) -> Vec<PackageItem> {
    let items = all_official();
    if !items.is_empty() {
        return items;
    }
    super::persist::load_from_disk(path);
    all_official()
}

#[cfg(test)]
mod tests {
    #[test]
    /// What: Return empty vector when the query is blank.
    ///
    /// Inputs:
    /// - Seed index with an entry and call `search_official` using whitespace-only query.
    ///
    /// Output:
    /// - Empty result set.
    ///
    /// Details:
    /// - Confirms whitespace trimming logic works.
    fn search_official_empty_query_returns_empty() {
        if let Ok(mut g) = super::idx().write() {
            g.pkgs = vec![crate::index::OfficialPkg {
                name: "example".to_string(),
                repo: "core".to_string(),
                arch: "x86_64".to_string(),
                version: "1.0".to_string(),
                description: "desc".to_string(),
            }];
        }
        let res = super::search_official("   ", false);
        assert!(res.is_empty());
    }

    #[test]
    /// What: Perform case-insensitive matching and field mapping.
    ///
    /// Inputs:
    /// - Seed index with uppercase/lowercase packages and query with lowercase substring.
    ///
    /// Output:
    /// - Single result matching expected fields.
    ///
    /// Details:
    /// - Verifies `Source::Official` metadata is preserved in mapped items.
    fn search_official_is_case_insensitive_and_maps_fields() {
        if let Ok(mut g) = super::idx().write() {
            g.pkgs = vec![
                crate::index::OfficialPkg {
                    name: "PacSea".to_string(),
                    repo: "core".to_string(),
                    arch: "x86_64".to_string(),
                    version: "1.2.3".to_string(),
                    description: "awesome".to_string(),
                },
                crate::index::OfficialPkg {
                    name: "other".to_string(),
                    repo: "extra".to_string(),
                    arch: "any".to_string(),
                    version: "0.1".to_string(),
                    description: "meh".to_string(),
                },
            ];
        }
        let res = super::search_official("pac", false);
        assert_eq!(res.len(), 1);
        let (item, _) = &res[0];
        assert_eq!(item.name, "PacSea");
        assert_eq!(item.version, "1.2.3");
        assert_eq!(item.description, "awesome");
        match &item.source {
            crate::state::Source::Official { repo, arch } => {
                assert_eq!(repo, "core");
                assert_eq!(arch, "x86_64");
            }
            crate::state::Source::Aur => panic!("expected Source::Official"),
        }
    }

    #[test]
    /// What: Populate all official packages regardless of query.
    ///
    /// Inputs:
    /// - Seed index with two packages and call `all_official`.
    ///
    /// Output:
    /// - Vector containing both packages.
    ///
    /// Details:
    /// - Checks ordering is not enforced but the returned names set matches expectation.
    fn all_official_returns_all_items() {
        if let Ok(mut g) = super::idx().write() {
            g.pkgs = vec![
                crate::index::OfficialPkg {
                    name: "aa".to_string(),
                    repo: "core".to_string(),
                    arch: "x86_64".to_string(),
                    version: "1".to_string(),
                    description: "A".to_string(),
                },
                crate::index::OfficialPkg {
                    name: "zz".to_string(),
                    repo: "extra".to_string(),
                    arch: "any".to_string(),
                    version: "2".to_string(),
                    description: "Z".to_string(),
                },
            ];
        }
        let items = super::all_official();
        assert_eq!(items.len(), 2);
        let mut names: Vec<String> = items.into_iter().map(|p| p.name).collect();
        names.sort();
        assert_eq!(names, vec!["aa", "zz"]);
    }

    #[tokio::test]
    /// What: Load packages from disk when the in-memory index is empty.
    ///
    /// Inputs:
    /// - Clear the index and provide a temp JSON file with one package.
    ///
    /// Output:
    /// - Vector containing the package from disk.
    ///
    /// Details:
    /// - Ensures fallback to `persist::load_from_disk` is exercised.
    async fn all_official_or_fetch_reads_from_disk_when_empty() {
        use std::path::PathBuf;
        if let Ok(mut g) = super::idx().write() {
            g.pkgs.clear();
        }
        let mut path: PathBuf = std::env::temp_dir();
        path.push(format!(
            "pacsea_idx_query_fetch_{}_{}.json",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .expect("System time is before UNIX epoch")
                .as_nanos()
        ));
        let idx_json = serde_json::json!({
            "pkgs": [
                {"name": "foo", "repo": "core", "arch": "x86_64", "version": "1", "description": ""}
            ]
        });
        std::fs::write(
            &path,
            serde_json::to_string(&idx_json).expect("failed to serialize index JSON"),
        )
        .expect("failed to write index JSON file");
        let items = super::all_official_or_fetch(&path);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].name, "foo");
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    /// What: Verify fuzzy search finds non-substring matches and normal search still works.
    ///
    /// Inputs:
    /// - Seed index with packages and test both fuzzy and normal search modes.
    ///
    /// Output:
    /// - Fuzzy mode finds "ripgrep" with query "rg", normal mode does not.
    /// - Normal mode finds substring matches as before.
    ///
    /// Details:
    /// - Tests that fuzzy matching enables finding packages by character sequence matching.
    fn search_official_fuzzy_vs_normal() {
        if let Ok(mut g) = super::idx().write() {
            g.pkgs = vec![
                crate::index::OfficialPkg {
                    name: "ripgrep".to_string(),
                    repo: "core".to_string(),
                    arch: "x86_64".to_string(),
                    version: "1.0".to_string(),
                    description: "fast grep".to_string(),
                },
                crate::index::OfficialPkg {
                    name: "other".to_string(),
                    repo: "extra".to_string(),
                    arch: "any".to_string(),
                    version: "0.1".to_string(),
                    description: "meh".to_string(),
                },
            ];
        }

        // Normal mode: "rg" should not match "ripgrep" (not a substring)
        let res_normal = super::search_official("rg", false);
        assert_eq!(res_normal.len(), 0);

        // Fuzzy mode: "rg" should match "ripgrep" (fuzzy match)
        let res_fuzzy = super::search_official("rg", true);
        assert_eq!(res_fuzzy.len(), 1);
        let (item, score) = &res_fuzzy[0];
        assert_eq!(item.name, "ripgrep");
        assert!(score.is_some());

        // Both modes should find "rip" (substring match)
        let res_normal2 = super::search_official("rip", false);
        assert_eq!(res_normal2.len(), 1);
        let res_fuzzy2 = super::search_official("rip", true);
        assert_eq!(res_fuzzy2.len(), 1);
    }
}