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}