pacsea/index/
query.rs

1use crate::state::{PackageItem, Source};
2
3use super::idx;
4
5/// What: Search the official index for packages whose names match `query`.
6///
7/// Inputs:
8/// - `query`: Raw query string
9/// - `fuzzy`: When `true`, uses fuzzy matching (fzf-style); when `false`, uses substring matching
10///
11/// Output:
12/// - Vector of `PackageItem`s populated from the index; enrichment is not performed here.
13///   An empty or whitespace-only query returns an empty list.
14///   When fuzzy mode is enabled, items are returned with scores for sorting.
15///
16/// Details:
17/// - When `fuzzy` is `false`, performs a case-insensitive substring match on package names.
18/// - When `fuzzy` is `true`, uses fuzzy matching and returns items with match scores.
19#[must_use]
20pub fn search_official(query: &str, fuzzy: bool) -> Vec<(PackageItem, Option<i64>)> {
21    let ql = query.trim();
22    if ql.is_empty() {
23        return Vec::new();
24    }
25    let mut items = Vec::new();
26    if let Ok(g) = idx().read() {
27        // Create matcher once per search query for better performance
28        let fuzzy_matcher = if fuzzy {
29            Some(fuzzy_matcher::skim::SkimMatcherV2::default())
30        } else {
31            None
32        };
33        for p in &g.pkgs {
34            let match_score = if fuzzy {
35                fuzzy_matcher
36                    .as_ref()
37                    .and_then(|m| crate::util::fuzzy_match_rank_with_matcher(&p.name, ql, m))
38            } else {
39                let nl = p.name.to_lowercase();
40                let ql_lower = ql.to_lowercase();
41                if nl.contains(&ql_lower) {
42                    Some(0) // Use 0 as placeholder score for substring matches
43                } else {
44                    None
45                }
46            };
47            if let Some(score) = match_score {
48                items.push((
49                    PackageItem {
50                        name: p.name.clone(),
51                        version: p.version.clone(),
52                        description: p.description.clone(),
53                        source: Source::Official {
54                            repo: p.repo.clone(),
55                            arch: p.arch.clone(),
56                        },
57                        popularity: None,
58                        out_of_date: None,
59                        orphaned: false,
60                    },
61                    Some(score),
62                ));
63            }
64        }
65    }
66    items
67}
68
69/// What: Return the entire official index as a list of `PackageItem`s.
70///
71/// Inputs:
72/// - None
73///
74/// Output:
75/// - Vector of all official items mapped to `PackageItem`.
76///
77/// Details:
78/// - Clones data from the shared index under a read lock and omits popularity data.
79#[must_use]
80pub fn all_official() -> Vec<PackageItem> {
81    let mut items = Vec::new();
82    if let Ok(g) = idx().read() {
83        items.reserve(g.pkgs.len());
84        for p in &g.pkgs {
85            items.push(PackageItem {
86                name: p.name.clone(),
87                version: p.version.clone(),
88                description: p.description.clone(),
89                source: Source::Official {
90                    repo: p.repo.clone(),
91                    arch: p.arch.clone(),
92                },
93                popularity: None,
94                out_of_date: None,
95                orphaned: false,
96            });
97        }
98    }
99    items
100}
101
102/// What: Return the entire official list; if empty, try to populate from disk and return it.
103///
104/// Inputs:
105/// - `path`: Path to on-disk JSON index to load as a fallback
106///
107/// Output:
108/// - Vector of `PackageItem`s representing the current in-memory (or loaded) index.
109///
110/// Details:
111/// - Loads from disk only when the in-memory list is empty to avoid redundant IO.
112#[must_use]
113pub fn all_official_or_fetch(path: &std::path::Path) -> Vec<PackageItem> {
114    let items = all_official();
115    if !items.is_empty() {
116        return items;
117    }
118    super::persist::load_from_disk(path);
119    all_official()
120}
121
122#[cfg(test)]
123mod tests {
124    #[test]
125    /// What: Return empty vector when the query is blank.
126    ///
127    /// Inputs:
128    /// - Seed index with an entry and call `search_official` using whitespace-only query.
129    ///
130    /// Output:
131    /// - Empty result set.
132    ///
133    /// Details:
134    /// - Confirms whitespace trimming logic works.
135    fn search_official_empty_query_returns_empty() {
136        if let Ok(mut g) = super::idx().write() {
137            g.pkgs = vec![crate::index::OfficialPkg {
138                name: "example".to_string(),
139                repo: "core".to_string(),
140                arch: "x86_64".to_string(),
141                version: "1.0".to_string(),
142                description: "desc".to_string(),
143            }];
144        }
145        let res = super::search_official("   ", false);
146        assert!(res.is_empty());
147    }
148
149    #[test]
150    /// What: Perform case-insensitive matching and field mapping.
151    ///
152    /// Inputs:
153    /// - Seed index with uppercase/lowercase packages and query with lowercase substring.
154    ///
155    /// Output:
156    /// - Single result matching expected fields.
157    ///
158    /// Details:
159    /// - Verifies `Source::Official` metadata is preserved in mapped items.
160    fn search_official_is_case_insensitive_and_maps_fields() {
161        if let Ok(mut g) = super::idx().write() {
162            g.pkgs = vec![
163                crate::index::OfficialPkg {
164                    name: "PacSea".to_string(),
165                    repo: "core".to_string(),
166                    arch: "x86_64".to_string(),
167                    version: "1.2.3".to_string(),
168                    description: "awesome".to_string(),
169                },
170                crate::index::OfficialPkg {
171                    name: "other".to_string(),
172                    repo: "extra".to_string(),
173                    arch: "any".to_string(),
174                    version: "0.1".to_string(),
175                    description: "meh".to_string(),
176                },
177            ];
178        }
179        let res = super::search_official("pac", false);
180        assert_eq!(res.len(), 1);
181        let (item, _) = &res[0];
182        assert_eq!(item.name, "PacSea");
183        assert_eq!(item.version, "1.2.3");
184        assert_eq!(item.description, "awesome");
185        match &item.source {
186            crate::state::Source::Official { repo, arch } => {
187                assert_eq!(repo, "core");
188                assert_eq!(arch, "x86_64");
189            }
190            crate::state::Source::Aur => panic!("expected Source::Official"),
191        }
192    }
193
194    #[test]
195    /// What: Populate all official packages regardless of query.
196    ///
197    /// Inputs:
198    /// - Seed index with two packages and call `all_official`.
199    ///
200    /// Output:
201    /// - Vector containing both packages.
202    ///
203    /// Details:
204    /// - Checks ordering is not enforced but the returned names set matches expectation.
205    fn all_official_returns_all_items() {
206        if let Ok(mut g) = super::idx().write() {
207            g.pkgs = vec![
208                crate::index::OfficialPkg {
209                    name: "aa".to_string(),
210                    repo: "core".to_string(),
211                    arch: "x86_64".to_string(),
212                    version: "1".to_string(),
213                    description: "A".to_string(),
214                },
215                crate::index::OfficialPkg {
216                    name: "zz".to_string(),
217                    repo: "extra".to_string(),
218                    arch: "any".to_string(),
219                    version: "2".to_string(),
220                    description: "Z".to_string(),
221                },
222            ];
223        }
224        let items = super::all_official();
225        assert_eq!(items.len(), 2);
226        let mut names: Vec<String> = items.into_iter().map(|p| p.name).collect();
227        names.sort();
228        assert_eq!(names, vec!["aa", "zz"]);
229    }
230
231    #[tokio::test]
232    /// What: Load packages from disk when the in-memory index is empty.
233    ///
234    /// Inputs:
235    /// - Clear the index and provide a temp JSON file with one package.
236    ///
237    /// Output:
238    /// - Vector containing the package from disk.
239    ///
240    /// Details:
241    /// - Ensures fallback to `persist::load_from_disk` is exercised.
242    async fn all_official_or_fetch_reads_from_disk_when_empty() {
243        use std::path::PathBuf;
244        if let Ok(mut g) = super::idx().write() {
245            g.pkgs.clear();
246        }
247        let mut path: PathBuf = std::env::temp_dir();
248        path.push(format!(
249            "pacsea_idx_query_fetch_{}_{}.json",
250            std::process::id(),
251            std::time::SystemTime::now()
252                .duration_since(std::time::UNIX_EPOCH)
253                .expect("System time is before UNIX epoch")
254                .as_nanos()
255        ));
256        let idx_json = serde_json::json!({
257            "pkgs": [
258                {"name": "foo", "repo": "core", "arch": "x86_64", "version": "1", "description": ""}
259            ]
260        });
261        std::fs::write(
262            &path,
263            serde_json::to_string(&idx_json).expect("failed to serialize index JSON"),
264        )
265        .expect("failed to write index JSON file");
266        let items = super::all_official_or_fetch(&path);
267        assert_eq!(items.len(), 1);
268        assert_eq!(items[0].name, "foo");
269        let _ = std::fs::remove_file(&path);
270    }
271
272    #[test]
273    /// What: Verify fuzzy search finds non-substring matches and normal search still works.
274    ///
275    /// Inputs:
276    /// - Seed index with packages and test both fuzzy and normal search modes.
277    ///
278    /// Output:
279    /// - Fuzzy mode finds "ripgrep" with query "rg", normal mode does not.
280    /// - Normal mode finds substring matches as before.
281    ///
282    /// Details:
283    /// - Tests that fuzzy matching enables finding packages by character sequence matching.
284    fn search_official_fuzzy_vs_normal() {
285        if let Ok(mut g) = super::idx().write() {
286            g.pkgs = vec![
287                crate::index::OfficialPkg {
288                    name: "ripgrep".to_string(),
289                    repo: "core".to_string(),
290                    arch: "x86_64".to_string(),
291                    version: "1.0".to_string(),
292                    description: "fast grep".to_string(),
293                },
294                crate::index::OfficialPkg {
295                    name: "other".to_string(),
296                    repo: "extra".to_string(),
297                    arch: "any".to_string(),
298                    version: "0.1".to_string(),
299                    description: "meh".to_string(),
300                },
301            ];
302        }
303
304        // Normal mode: "rg" should not match "ripgrep" (not a substring)
305        let res_normal = super::search_official("rg", false);
306        assert_eq!(res_normal.len(), 0);
307
308        // Fuzzy mode: "rg" should match "ripgrep" (fuzzy match)
309        let res_fuzzy = super::search_official("rg", true);
310        assert_eq!(res_fuzzy.len(), 1);
311        let (item, score) = &res_fuzzy[0];
312        assert_eq!(item.name, "ripgrep");
313        assert!(score.is_some());
314
315        // Both modes should find "rip" (substring match)
316        let res_normal2 = super::search_official("rip", false);
317        assert_eq!(res_normal2.len(), 1);
318        let res_fuzzy2 = super::search_official("rip", true);
319        assert_eq!(res_fuzzy2.len(), 1);
320    }
321}