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}