Skip to main content

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}