arch_toolkit/index/query.rs
1//! Official repository package search functions for the index module.
2
3use crate::types::index::{IndexQueryResult, OfficialIndex, OfficialPackage};
4
5/// What: Search the official index for packages whose names match `query`.
6///
7/// Inputs:
8/// - `index`: Reference to the official package index to search.
9/// - `query`: Raw query string to match against package names.
10/// - `fuzzy`: When `true`, uses fuzzy matching (fzf-style); when `false`, uses substring matching.
11///
12/// Output:
13/// - Vector of `IndexQueryResult` containing matched packages with optional fuzzy scores.
14/// - An empty or whitespace-only query returns an empty list.
15/// - When fuzzy mode is enabled, items are returned with scores for sorting.
16///
17/// Details:
18/// - When `fuzzy` is `false`, performs a case-insensitive substring match on package names.
19/// - When `fuzzy` is `true`, uses fuzzy matching and returns items with match scores.
20/// - Fuzzy matching requires the `fuzzy-search` feature flag; if not available, falls back to substring matching.
21/// - Fuzzy results are sorted by score (best first), ties broken by name;
22/// substring results keep the index order.
23///
24/// # Example
25///
26/// ```no_run
27/// use arch_toolkit::index::{search_official, OfficialIndex};
28///
29/// let index = OfficialIndex::default();
30/// // Substring matching
31/// let results = search_official(&index, "vim", false);
32/// // Fuzzy matching (requires fuzzy-search feature)
33/// let fuzzy_results = search_official(&index, "rg", true);
34/// ```
35#[must_use]
36pub fn search_official(index: &OfficialIndex, query: &str, fuzzy: bool) -> Vec<IndexQueryResult> {
37 let ql = query.trim();
38 if ql.is_empty() {
39 return Vec::new();
40 }
41
42 let mut results = Vec::new();
43
44 // Create fuzzy matcher if fuzzy matching is requested and available
45 #[cfg(feature = "fuzzy-search")]
46 let fuzzy_matcher = if fuzzy {
47 Some(fuzzy_matcher::skim::SkimMatcherV2::default())
48 } else {
49 None
50 };
51
52 // If fuzzy-search feature is not available, always use substring matching
53 // (fuzzy parameter is part of API but ignored when feature disabled)
54 #[cfg(not(feature = "fuzzy-search"))]
55 let _ = fuzzy; // Acknowledge parameter exists but not used without feature
56 #[cfg(not(feature = "fuzzy-search"))]
57 let use_fuzzy = false;
58 #[cfg(feature = "fuzzy-search")]
59 let use_fuzzy = fuzzy;
60
61 for pkg in &index.pkgs {
62 let match_score = if use_fuzzy {
63 #[cfg(feature = "fuzzy-search")]
64 {
65 fuzzy_matcher.as_ref().and_then(|m| {
66 use fuzzy_matcher::FuzzyMatcher;
67 m.fuzzy_match(&pkg.name, ql)
68 })
69 }
70 #[cfg(not(feature = "fuzzy-search"))]
71 {
72 None
73 }
74 } else {
75 // Case-insensitive substring matching
76 let name_lower = pkg.name.to_lowercase();
77 let query_lower = ql.to_lowercase();
78 if name_lower.contains(&query_lower) {
79 Some(0) // Use 0 as placeholder score for substring matches
80 } else {
81 None
82 }
83 };
84
85 if let Some(score) = match_score {
86 results.push(IndexQueryResult {
87 package: pkg.clone(),
88 fuzzy_score: if use_fuzzy { Some(score) } else { None },
89 });
90 }
91 }
92
93 if use_fuzzy {
94 results.sort_by(|a, b| {
95 b.fuzzy_score
96 .cmp(&a.fuzzy_score)
97 .then_with(|| a.package.name.cmp(&b.package.name))
98 });
99 }
100
101 results
102}
103
104/// What: Return all packages from the official index.
105///
106/// Inputs:
107/// - `index`: Reference to the official package index.
108///
109/// Output:
110/// - Vector of all `OfficialPackage` entries from the index.
111///
112/// Details:
113/// - Clones all packages from the index.
114/// - Order is preserved from the index.
115///
116/// # Example
117///
118/// ```no_run
119/// use arch_toolkit::index::{all_official, OfficialIndex};
120///
121/// let index = OfficialIndex::default();
122/// let all_packages = all_official(&index);
123/// println!("Found {} official packages", all_packages.len());
124/// ```
125#[must_use]
126pub fn all_official(index: &OfficialIndex) -> Vec<OfficialPackage> {
127 index.pkgs.clone()
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::types::index::OfficialIndex;
134
135 fn create_test_index() -> OfficialIndex {
136 let mut index = OfficialIndex {
137 pkgs: vec![
138 OfficialPackage {
139 name: "ripgrep".to_string(),
140 repo: "extra".to_string(),
141 arch: "x86_64".to_string(),
142 version: "14.0.0".to_string(),
143 description: "Fast grep tool".to_string(),
144 },
145 OfficialPackage {
146 name: "vim".to_string(),
147 repo: "extra".to_string(),
148 arch: "x86_64".to_string(),
149 version: "9.0".to_string(),
150 description: "Text editor".to_string(),
151 },
152 OfficialPackage {
153 name: "pacman".to_string(),
154 repo: "core".to_string(),
155 arch: "x86_64".to_string(),
156 version: "6.1.0".to_string(),
157 description: "Package manager".to_string(),
158 },
159 ],
160 name_to_idx: std::collections::HashMap::new(),
161 };
162 index.rebuild_name_index();
163 index
164 }
165
166 #[test]
167 /// What: Verify `search_official` returns empty vector for empty query.
168 ///
169 /// Inputs:
170 /// - Empty query string and whitespace-only query.
171 ///
172 /// Output:
173 /// - Empty result set for both cases.
174 ///
175 /// Details:
176 /// - Tests that whitespace trimming logic works correctly.
177 fn search_official_empty_query_returns_empty() {
178 let index = create_test_index();
179 assert!(search_official(&index, "", false).is_empty());
180 assert!(search_official(&index, " ", false).is_empty());
181 assert!(search_official(&index, "\t\n", false).is_empty());
182 }
183
184 #[test]
185 /// What: Verify `search_official` performs case-insensitive substring matching.
186 ///
187 /// Inputs:
188 /// - Query with different case variations.
189 ///
190 /// Output:
191 /// - Results match regardless of case.
192 ///
193 /// Details:
194 /// - Tests that substring matching is case-insensitive.
195 fn search_official_case_insensitive_substring() {
196 let index = create_test_index();
197 let results_lower = search_official(&index, "vim", false);
198 let results_upper = search_official(&index, "VIM", false);
199 let results_mixed = search_official(&index, "ViM", false);
200
201 assert_eq!(results_lower.len(), 1);
202 assert_eq!(results_upper.len(), 1);
203 assert_eq!(results_mixed.len(), 1);
204 assert_eq!(results_lower[0].package.name, "vim");
205 }
206
207 #[test]
208 /// What: Verify `search_official` finds partial matches.
209 ///
210 /// Inputs:
211 /// - Query that matches part of package name.
212 ///
213 /// Output:
214 /// - Results include packages with matching substring.
215 ///
216 /// Details:
217 /// - Tests that substring matching works for partial names.
218 fn search_official_partial_match() {
219 let index = create_test_index();
220 let results = search_official(&index, "rip", false);
221 assert_eq!(results.len(), 1);
222 assert_eq!(results[0].package.name, "ripgrep");
223 assert_eq!(results[0].fuzzy_score, None); // Substring match has no fuzzy score
224 }
225
226 #[test]
227 /// What: Verify `search_official` with fuzzy matching (if feature enabled).
228 ///
229 /// Inputs:
230 /// - Query that doesn't match as substring but should match fuzzy.
231 ///
232 /// Output:
233 /// - Results include fuzzy matches with scores.
234 ///
235 /// Details:
236 /// - Tests that fuzzy matching finds non-substring matches.
237 /// - Only runs if fuzzy-search feature is enabled.
238 #[cfg(feature = "fuzzy-search")]
239 fn search_official_fuzzy_match() {
240 let index = create_test_index();
241 // "rg" should match "ripgrep" with fuzzy matching but not substring
242 let substring_results = search_official(&index, "rg", false);
243 let fuzzy_results = search_official(&index, "rg", true);
244
245 assert_eq!(substring_results.len(), 0); // No substring match
246 assert_eq!(fuzzy_results.len(), 1); // Fuzzy match found
247 assert_eq!(fuzzy_results[0].package.name, "ripgrep");
248 assert!(fuzzy_results[0].fuzzy_score.is_some()); // Has fuzzy score
249 }
250
251 #[test]
252 /// What: Verify `search_official` graceful degradation when fuzzy-search feature is disabled.
253 ///
254 /// Inputs:
255 /// - Query with fuzzy=true but feature not available.
256 ///
257 /// Output:
258 /// - Falls back to substring matching.
259 ///
260 /// Details:
261 /// - Tests graceful degradation when fuzzy-search feature is not enabled.
262 #[cfg(not(feature = "fuzzy-search"))]
263 fn search_official_fuzzy_fallback() {
264 let index = create_test_index();
265 // When fuzzy-search is not available, fuzzy=true should fall back to substring
266 let results = search_official(&index, "rip", true);
267 assert_eq!(results.len(), 1);
268 assert_eq!(results[0].package.name, "ripgrep");
269 }
270
271 #[test]
272 /// What: Verify `all_official` returns all packages.
273 ///
274 /// Inputs:
275 /// - Index with multiple packages.
276 ///
277 /// Output:
278 /// - Vector containing all packages from index.
279 ///
280 /// Details:
281 /// - Tests that all packages are returned in correct order.
282 fn all_official_returns_all_packages() {
283 let index = create_test_index();
284 let all = all_official(&index);
285 assert_eq!(all.len(), 3);
286
287 let names: Vec<String> = all.iter().map(|p| p.name.clone()).collect();
288 assert!(names.contains(&"ripgrep".to_string()));
289 assert!(names.contains(&"vim".to_string()));
290 assert!(names.contains(&"pacman".to_string()));
291 }
292
293 #[test]
294 /// What: Verify `all_official` returns empty vector for empty index.
295 ///
296 /// Inputs:
297 /// - Empty index.
298 ///
299 /// Output:
300 /// - Empty vector.
301 ///
302 /// Details:
303 /// - Tests that empty index returns empty results.
304 fn all_official_empty_index() {
305 let index = OfficialIndex::default();
306 let all = all_official(&index);
307 assert!(all.is_empty());
308 }
309}