Skip to main content

arch_toolkit/types/
index.rs

1//! Index-related data types for official repository package operations.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7/// What: Capture the minimal metadata about an official package entry.
8///
9/// Inputs:
10/// - Populated primarily from `pacman -Sl`/API responses with optional enrichment.
11///
12/// Output:
13/// - Serves as the source of truth for official repository package information.
14///
15/// Details:
16/// - Represents a package from official Arch Linux repositories.
17/// - Non-name fields may be empty initially; enrichment routines fill them lazily.
18/// - Serializable via Serde to allow saving and restoring across sessions.
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct OfficialPackage {
21    /// Package name.
22    pub name: String,
23    /// Repository name (e.g., "core", "extra", "community").
24    #[serde(default, skip_serializing_if = "String::is_empty")]
25    pub repo: String,
26    /// Target architecture (e.g., `x86_64`, `any`).
27    #[serde(default, skip_serializing_if = "String::is_empty")]
28    pub arch: String,
29    /// Package version.
30    #[serde(default, skip_serializing_if = "String::is_empty")]
31    pub version: String,
32    /// Package description.
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub description: String,
35}
36
37/// What: Represent the full collection of official packages maintained in memory.
38///
39/// Inputs:
40/// - Populated by fetch and enrichment routines before being persisted or queried.
41///
42/// Output:
43/// - Exposed through API helpers that clone or iterate the package list.
44///
45/// Details:
46/// - Serializable via Serde to allow saving and restoring across sessions.
47/// - The `name_to_idx` field is derived from `pkgs` and skipped during serialization.
48/// - Provides O(1) lookup via `find_package_by_name()` when `name_to_idx` is populated.
49#[derive(Clone, Debug, Default, Serialize, Deserialize)]
50pub struct OfficialIndex {
51    /// All known official packages in the index.
52    pub pkgs: Vec<OfficialPackage>,
53    /// Index mapping lowercase package names to their position in `pkgs` for O(1) lookups.
54    /// Skipped during serialization; rebuilt after deserialization via `rebuild_name_index()`.
55    #[serde(skip)]
56    pub name_to_idx: HashMap<String, usize>,
57}
58
59impl OfficialIndex {
60    /// What: Rebuild the `name_to_idx` `HashMap` from the current `pkgs` Vec.
61    ///
62    /// Inputs:
63    /// - None (operates on `self.pkgs`)
64    ///
65    /// Output:
66    /// - Populates `self.name_to_idx` with lowercase package names mapped to indices.
67    ///
68    /// Details:
69    /// - Should be called after deserialization or when `pkgs` is modified.
70    /// - Uses lowercase names for case-insensitive lookups.
71    /// - Clears existing index before rebuilding to ensure consistency.
72    pub fn rebuild_name_index(&mut self) {
73        self.name_to_idx.clear();
74        self.name_to_idx.reserve(self.pkgs.len());
75        for (i, pkg) in self.pkgs.iter().enumerate() {
76            self.name_to_idx.insert(pkg.name.to_lowercase(), i);
77        }
78    }
79
80    /// What: Find a package by name in the official index using O(1) lookup.
81    ///
82    /// Inputs:
83    /// - `name`: Package name to search for (case-insensitive)
84    ///
85    /// Output:
86    /// - `Some(&OfficialPackage)` if the package is found, `None` otherwise.
87    ///
88    /// Details:
89    /// - Uses the `name_to_idx` `HashMap` for O(1) lookup by lowercase name.
90    /// - Falls back to linear scan if `HashMap` is empty (e.g., before rebuild).
91    /// - Case-insensitive matching is performed.
92    #[must_use]
93    pub fn find_package_by_name(&self, name: &str) -> Option<&OfficialPackage> {
94        // Try O(1) HashMap lookup first
95        let name_lower = name.to_lowercase();
96        if let Some(&idx) = self.name_to_idx.get(&name_lower) {
97            return self.pkgs.get(idx);
98        }
99        // Fallback to linear scan if HashMap is empty or index mismatch
100        self.pkgs.iter().find(|p| p.name.eq_ignore_ascii_case(name))
101    }
102}
103
104/// What: Search result with optional fuzzy matching score.
105///
106/// Inputs:
107/// - Created by search functions that match packages against queries.
108///
109/// Output:
110/// - Contains the matched package and its fuzzy score (if fuzzy matching was used).
111///
112/// Details:
113/// - Used to return search results with relevance scores for sorting.
114/// - `fuzzy_score` is `None` for exact or substring matches.
115/// - `fuzzy_score` is `Some(i64)` when fuzzy matching is enabled, with higher scores indicating better matches.
116#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct IndexQueryResult {
118    /// The matched package.
119    pub package: OfficialPackage,
120    /// Fuzzy matching score, if fuzzy matching was used.
121    pub fuzzy_score: Option<i64>,
122}
123
124/// What: Filter mode for querying explicitly installed packages.
125///
126/// Inputs:
127/// - Used as parameter to explicit package query functions.
128///
129/// Output:
130/// - Determines which pacman command arguments are used.
131///
132/// Details:
133/// - `LeafOnly`: Uses `pacman -Qetq` (explicitly installed AND not required by other packages).
134/// - `AllExplicit`: Uses `pacman -Qeq` (all explicitly installed packages, including dependencies).
135#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
136pub enum InstalledPackagesMode {
137    /// Query only leaf packages (explicitly installed and not required).
138    LeafOnly,
139    /// Query all explicitly installed packages.
140    AllExplicit,
141}
142
143/// What: Represent one mirror-status row that can produce a pacman server line.
144///
145/// Inputs:
146/// - Parsed from a caller-selected mirror-status JSON endpoint or constructed
147///   directly by callers.
148///
149/// Output:
150/// - Portable mirror metadata for deterministic filtering and mirrorlist generation.
151///
152/// Details:
153/// - `url` is a mirror base URL; generation appends `/$repo/os/$arch`.
154/// - `active` and `protocols` are source metadata, not a live health claim by
155///   this library.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
157pub struct MirrorInfo {
158    /// Mirror base URL, normally ending in `/`.
159    pub url: String,
160    /// Whether the discovery source marked the mirror active.
161    pub active: bool,
162    /// Transport protocols advertised by the discovery source.
163    pub protocols: Vec<String>,
164}
165
166/// What: Hold explicit bounds for caller-client mirror-status discovery.
167///
168/// Inputs:
169/// - Constructed directly or with [`MirrorDiscoveryLimits::default`].
170///
171/// Output:
172/// - Maximum response bytes and accepted mirror rows for one discovery request.
173///
174/// Details:
175/// - Bounds apply before JSON parsing and while accepting valid rows, keeping
176///   remote endpoint responses from causing unbounded resource use.
177#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct MirrorDiscoveryLimits {
179    /// Maximum response-body bytes accepted before JSON parsing.
180    pub max_response_bytes: usize,
181    /// Maximum valid mirror rows returned to the caller.
182    pub max_mirrors: usize,
183}
184
185impl Default for MirrorDiscoveryLimits {
186    /// What: Provide conservative default bounds for one mirror-status request.
187    ///
188    /// Inputs: None.
189    ///
190    /// Output:
191    /// - A 512 KiB response bound and 128 returned mirrors.
192    ///
193    /// Details:
194    /// - Callers can choose tighter limits for constrained environments or
195    ///   larger explicit limits when their application has reviewed the cost.
196    fn default() -> Self {
197        Self {
198            max_response_bytes: 512 * 1024,
199            max_mirrors: 128,
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    /// What: Verify `rebuild_name_index` populates `HashMap` correctly.
210    ///
211    /// Inputs:
212    /// - `OfficialIndex` with two packages.
213    ///
214    /// Output:
215    /// - `HashMap` contains lowercase names mapped to correct indices.
216    ///
217    /// Details:
218    /// - Tests that the `HashMap` is built correctly and supports case-insensitive lookups.
219    fn rebuild_name_index_populates_hashmap() {
220        let mut index = OfficialIndex {
221            pkgs: vec![
222                OfficialPackage {
223                    name: "PackageA".to_string(),
224                    repo: "core".to_string(),
225                    arch: "x86_64".to_string(),
226                    version: "1.0".to_string(),
227                    description: "Desc A".to_string(),
228                },
229                OfficialPackage {
230                    name: "PackageB".to_string(),
231                    repo: "extra".to_string(),
232                    arch: "any".to_string(),
233                    version: "2.0".to_string(),
234                    description: "Desc B".to_string(),
235                },
236            ],
237            name_to_idx: HashMap::new(),
238        };
239
240        index.rebuild_name_index();
241
242        assert_eq!(index.name_to_idx.len(), 2);
243        assert_eq!(index.name_to_idx.get("packagea"), Some(&0));
244        assert_eq!(index.name_to_idx.get("packageb"), Some(&1));
245        // Original case should not be found
246        assert_eq!(index.name_to_idx.get("PackageA"), None);
247    }
248
249    #[test]
250    /// What: Verify `find_package_by_name` uses `HashMap` for O(1) lookup.
251    ///
252    /// Inputs:
253    /// - Seed index with packages and rebuilt `HashMap`.
254    ///
255    /// Output:
256    /// - Package found via case-insensitive name lookup.
257    ///
258    /// Details:
259    /// - Tests that find works with different case variations.
260    fn find_package_by_name_uses_hashmap() {
261        let mut index = OfficialIndex {
262            pkgs: vec![
263                OfficialPackage {
264                    name: "ripgrep".to_string(),
265                    repo: "extra".to_string(),
266                    arch: "x86_64".to_string(),
267                    version: "14.0.0".to_string(),
268                    description: "Fast grep".to_string(),
269                },
270                OfficialPackage {
271                    name: "vim".to_string(),
272                    repo: "extra".to_string(),
273                    arch: "x86_64".to_string(),
274                    version: "9.0".to_string(),
275                    description: "Text editor".to_string(),
276                },
277            ],
278            name_to_idx: HashMap::new(),
279        };
280
281        index.rebuild_name_index();
282
283        // Test exact case
284        let result = index.find_package_by_name("ripgrep");
285        assert!(result.is_some());
286        assert_eq!(result.map(|p| p.name.as_str()), Some("ripgrep"));
287
288        // Test different case (HashMap uses lowercase)
289        let result_upper = index.find_package_by_name("RIPGREP");
290        assert!(result_upper.is_some());
291        assert_eq!(result_upper.map(|p| p.name.as_str()), Some("ripgrep"));
292
293        // Test non-existent package
294        let not_found = index.find_package_by_name("nonexistent");
295        assert!(not_found.is_none());
296    }
297
298    #[test]
299    /// What: Verify `find_package_by_name` falls back to linear scan when `HashMap` is empty.
300    ///
301    /// Inputs:
302    /// - `OfficialIndex` with packages but empty `name_to_idx`.
303    ///
304    /// Output:
305    /// - Package found via linear scan fallback.
306    ///
307    /// Details:
308    /// - Tests that the fallback mechanism works when index is not rebuilt.
309    fn find_package_by_name_fallback_to_linear_scan() {
310        let index = OfficialIndex {
311            pkgs: vec![OfficialPackage {
312                name: "test-package".to_string(),
313                repo: "core".to_string(),
314                arch: "x86_64".to_string(),
315                version: "1.0".to_string(),
316                description: "Test".to_string(),
317            }],
318            name_to_idx: HashMap::new(),
319        };
320
321        // Should still find package via linear scan
322        let result = index.find_package_by_name("test-package");
323        assert!(result.is_some());
324        assert_eq!(result.map(|p| p.name.as_str()), Some("test-package"));
325
326        // Case-insensitive fallback
327        let result_upper = index.find_package_by_name("TEST-PACKAGE");
328        assert!(result_upper.is_some());
329    }
330
331    #[test]
332    /// What: Verify serialization and deserialization of `OfficialIndex`.
333    ///
334    /// Inputs:
335    /// - `OfficialIndex` with packages and rebuilt name index.
336    ///
337    /// Output:
338    /// - Deserialized index matches original, and name index can be rebuilt.
339    ///
340    /// Details:
341    /// - Tests that `name_to_idx` is skipped during serialization.
342    /// - Verifies that index can be rebuilt after deserialization.
343    fn serialization_deserialization() {
344        let mut index = OfficialIndex {
345            pkgs: vec![
346                OfficialPackage {
347                    name: "package1".to_string(),
348                    repo: "core".to_string(),
349                    arch: "x86_64".to_string(),
350                    version: "1.0".to_string(),
351                    description: "Package 1".to_string(),
352                },
353                OfficialPackage {
354                    name: "package2".to_string(),
355                    repo: "extra".to_string(),
356                    arch: "any".to_string(),
357                    version: "2.0".to_string(),
358                    description: "Package 2".to_string(),
359                },
360            ],
361            name_to_idx: HashMap::new(),
362        };
363
364        index.rebuild_name_index();
365
366        // Serialize
367        let json = serde_json::to_string(&index).expect("Serialization should succeed");
368        assert!(!json.contains("name_to_idx")); // Should be skipped
369
370        // Deserialize
371        let mut deserialized: OfficialIndex =
372            serde_json::from_str(&json).expect("Deserialization should succeed");
373        assert_eq!(deserialized.pkgs.len(), 2);
374        assert!(deserialized.name_to_idx.is_empty()); // Should be empty after deserialization
375
376        // Rebuild index
377        deserialized.rebuild_name_index();
378        assert_eq!(deserialized.name_to_idx.len(), 2);
379        assert_eq!(deserialized.name_to_idx.get("package1"), Some(&0));
380        assert_eq!(deserialized.name_to_idx.get("package2"), Some(&1));
381
382        // Verify find works after rebuild
383        let found = deserialized.find_package_by_name("package1");
384        assert!(found.is_some());
385        assert_eq!(found.map(|p| p.name.as_str()), Some("package1"));
386    }
387
388    #[test]
389    /// What: Verify `IndexQueryResult` creation and serialization.
390    ///
391    /// Inputs:
392    /// - `IndexQueryResult` with package and optional fuzzy score.
393    ///
394    /// Output:
395    /// - Result can be created and serialized correctly.
396    ///
397    /// Details:
398    /// - Tests both with and without fuzzy score.
399    fn index_query_result_creation() {
400        let package = OfficialPackage {
401            name: "test".to_string(),
402            repo: "core".to_string(),
403            arch: "x86_64".to_string(),
404            version: "1.0".to_string(),
405            description: "Test package".to_string(),
406        };
407
408        // With fuzzy score
409        let result_with_score = IndexQueryResult {
410            package: package.clone(),
411            fuzzy_score: Some(100),
412        };
413        assert_eq!(result_with_score.fuzzy_score, Some(100));
414
415        // Without fuzzy score
416        let result_without_score = IndexQueryResult {
417            package,
418            fuzzy_score: None,
419        };
420        assert_eq!(result_without_score.fuzzy_score, None);
421
422        // Serialization test
423        let json = serde_json::to_string(&result_with_score).expect("Should serialize");
424        let deserialized: IndexQueryResult =
425            serde_json::from_str(&json).expect("Should deserialize");
426        assert_eq!(deserialized.fuzzy_score, Some(100));
427        assert_eq!(deserialized.package.name, "test");
428    }
429}