pacsea/index/mod.rs
1//! Official package index management, persistence, and enrichment.
2//!
3//! Split into submodules for maintainability. Public API is re-exported
4//! to remain compatible with previous `crate::index` consumers.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::{OnceLock, RwLock};
8
9/// What: Represent the full collection of official packages maintained in memory.
10///
11/// Inputs:
12/// - Populated by fetch and enrichment routines before being persisted or queried.
13///
14/// Output:
15/// - Exposed through API helpers that clone or iterate the package list.
16///
17/// Details:
18/// - Serializable via Serde to allow saving and restoring across sessions.
19/// - The `name_to_idx` field is derived from `pkgs` and skipped during serialization.
20#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
21pub struct OfficialIndex {
22 /// All known official packages in the process-wide index.
23 pub pkgs: Vec<OfficialPkg>,
24 /// Index mapping lowercase package names to their position in `pkgs` for O(1) lookups.
25 /// Skipped during serialization; rebuilt after deserialization via `rebuild_name_index()`.
26 #[serde(skip)]
27 pub name_to_idx: HashMap<String, usize>,
28}
29
30impl OfficialIndex {
31 /// What: Rebuild the `name_to_idx` `HashMap` from the current `pkgs` Vec.
32 ///
33 /// Inputs:
34 /// - None (operates on `self.pkgs`)
35 ///
36 /// Output:
37 /// - Populates `self.name_to_idx` with lowercase package names mapped to indices.
38 ///
39 /// Details:
40 /// - Should be called after deserialization or when `pkgs` is modified.
41 /// - Uses lowercase names for case-insensitive lookups.
42 pub fn rebuild_name_index(&mut self) {
43 self.name_to_idx.clear();
44 self.name_to_idx.reserve(self.pkgs.len());
45 for (i, pkg) in self.pkgs.iter().enumerate() {
46 self.name_to_idx.insert(pkg.name.to_lowercase(), i);
47 }
48 }
49}
50
51/// What: Capture the minimal metadata about an official package entry.
52///
53/// Inputs:
54/// - Populated primarily from `pacman -Sl`/API responses with optional enrichment.
55///
56/// Output:
57/// - Serves as the source of truth for UI-facing `PackageItem` conversions.
58///
59/// Details:
60/// - Represents a package from official Arch Linux repositories.
61/// - Non-name fields may be empty initially; enrichment routines fill them lazily.
62#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
63pub struct OfficialPkg {
64 /// Package name.
65 pub name: String,
66 /// Repository name (e.g., "core", "extra", "community").
67 #[serde(default, skip_serializing_if = "String::is_empty")]
68 pub repo: String,
69 /// Target architecture (e.g., `x86_64`, `any`).
70 #[serde(default, skip_serializing_if = "String::is_empty")]
71 pub arch: String,
72 /// Package version.
73 #[serde(default, skip_serializing_if = "String::is_empty")]
74 pub version: String,
75 /// Package description.
76 #[serde(default, skip_serializing_if = "String::is_empty")]
77 pub description: String,
78}
79
80/// Process-wide holder for the official index state.
81static OFFICIAL_INDEX: OnceLock<RwLock<OfficialIndex>> = OnceLock::new();
82/// Process-wide set of installed package names.
83static INSTALLED_SET: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
84/// Process-wide set of explicitly-installed package names (dependency-free set).
85static EXPLICIT_SET: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
86
87mod distro;
88pub use distro::{
89 is_artix_galaxy, is_artix_lib32, is_artix_omniverse, is_artix_repo, is_artix_system,
90 is_artix_universe, is_artix_world, is_cachyos_repo, is_eos_name, is_eos_repo,
91 is_manjaro_name_or_owner, is_name_manjaro,
92};
93
94/// What: Access the process-wide `OfficialIndex` lock for mutation or reads.
95///
96/// Inputs:
97/// - None (initializes the underlying `OnceLock` on first use)
98///
99/// Output:
100/// - `&'static RwLock<OfficialIndex>` guard used to manipulate the shared index state.
101///
102/// Details:
103/// - Lazily seeds the index with an empty package list the first time it is accessed.
104fn idx() -> &'static RwLock<OfficialIndex> {
105 OFFICIAL_INDEX.get_or_init(|| {
106 RwLock::new(OfficialIndex {
107 pkgs: Vec::new(),
108 name_to_idx: HashMap::new(),
109 })
110 })
111}
112
113/// What: Access the process-wide lock protecting the installed-package name cache.
114///
115/// Inputs:
116/// - None (initializes the `OnceLock` on-demand)
117///
118/// Output:
119/// - `&'static RwLock<HashSet<String>>` with the cached installed-package names.
120///
121/// Details:
122/// - Lazily creates the shared `HashSet` the first time it is requested; subsequent calls reuse it.
123fn installed_lock() -> &'static RwLock<HashSet<String>> {
124 INSTALLED_SET.get_or_init(|| RwLock::new(HashSet::new()))
125}
126
127/// What: Access the process-wide lock protecting the explicit-package name cache.
128///
129/// Inputs:
130/// - None (initializes the `OnceLock` on-demand)
131///
132/// Output:
133/// - `&'static RwLock<HashSet<String>>` for explicitly installed package names.
134///
135/// Details:
136/// - Lazily creates the shared set the first time it is requested; subsequent calls reuse it.
137fn explicit_lock() -> &'static RwLock<HashSet<String>> {
138 EXPLICIT_SET.get_or_init(|| RwLock::new(HashSet::new()))
139}
140
141/// Package index enrichment utilities.
142mod enrich;
143/// Explicit package tracking.
144mod explicit;
145/// Package index fetching.
146mod fetch;
147/// Installed package utilities.
148mod installed;
149/// Package index persistence.
150mod persist;
151/// Package query utilities.
152mod query;
153
154#[cfg(windows)]
155/// Mirror configuration for Windows.
156mod mirrors;
157/// Package index update utilities.
158mod update;
159
160pub use enrich::*;
161pub use explicit::*;
162pub use installed::*;
163#[cfg(windows)]
164pub use mirrors::*;
165pub use persist::*;
166pub use query::*;
167#[cfg(not(windows))]
168pub use update::update_in_background;
169
170/// What: Find a package by name in the official index and return it as a `PackageItem`.
171///
172/// Inputs:
173/// - `name`: Package name to search for
174///
175/// Output:
176/// - `Some(PackageItem)` if the package is found in the official index, `None` otherwise.
177///
178/// Details:
179/// - Uses the `name_to_idx` `HashMap` for O(1) lookup by lowercase name.
180/// - Falls back to linear scan if `HashMap` is empty (e.g., before rebuild).
181#[must_use]
182pub fn find_package_by_name(name: &str) -> Option<crate::state::PackageItem> {
183 use crate::state::{PackageItem, Source};
184
185 if let Ok(g) = idx().read() {
186 // Try O(1) HashMap lookup first
187 let name_lower = name.to_lowercase();
188 if let Some(&idx) = g.name_to_idx.get(&name_lower)
189 && let Some(p) = g.pkgs.get(idx)
190 {
191 return Some(PackageItem {
192 name: p.name.clone(),
193 version: p.version.clone(),
194 description: p.description.clone(),
195 source: Source::Official {
196 repo: p.repo.clone(),
197 arch: p.arch.clone(),
198 },
199 popularity: None,
200 out_of_date: None,
201 orphaned: false,
202 });
203 }
204 // Fallback to linear scan if HashMap is empty or index mismatch
205 for p in &g.pkgs {
206 if p.name.eq_ignore_ascii_case(name) {
207 return Some(PackageItem {
208 name: p.name.clone(),
209 version: p.version.clone(),
210 description: p.description.clone(),
211 source: Source::Official {
212 repo: p.repo.clone(),
213 arch: p.arch.clone(),
214 },
215 popularity: None,
216 out_of_date: None,
217 orphaned: false,
218 });
219 }
220 }
221 }
222 None
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 /// What: Verify `rebuild_name_index` populates `HashMap` correctly.
231 ///
232 /// Inputs:
233 /// - `OfficialIndex` with two packages.
234 ///
235 /// Output:
236 /// - `HashMap` contains lowercase names mapped to correct indices.
237 ///
238 /// Details:
239 /// - Tests that the `HashMap` is built correctly and supports case-insensitive lookups.
240 fn rebuild_name_index_populates_hashmap() {
241 let mut index = OfficialIndex {
242 pkgs: vec![
243 OfficialPkg {
244 name: "PackageA".to_string(),
245 repo: "core".to_string(),
246 arch: "x86_64".to_string(),
247 version: "1.0".to_string(),
248 description: "Desc A".to_string(),
249 },
250 OfficialPkg {
251 name: "PackageB".to_string(),
252 repo: "extra".to_string(),
253 arch: "any".to_string(),
254 version: "2.0".to_string(),
255 description: "Desc B".to_string(),
256 },
257 ],
258 name_to_idx: HashMap::new(),
259 };
260
261 index.rebuild_name_index();
262
263 assert_eq!(index.name_to_idx.len(), 2);
264 assert_eq!(index.name_to_idx.get("packagea"), Some(&0));
265 assert_eq!(index.name_to_idx.get("packageb"), Some(&1));
266 // Original case should not be found
267 assert_eq!(index.name_to_idx.get("PackageA"), None);
268 }
269
270 #[test]
271 /// What: Verify `find_package_by_name` uses `HashMap` for O(1) lookup.
272 ///
273 /// Inputs:
274 /// - Seed index with packages and rebuilt `HashMap`.
275 ///
276 /// Output:
277 /// - Package found via case-insensitive name lookup.
278 ///
279 /// Details:
280 /// - Tests that find works with different case variations.
281 fn find_package_by_name_uses_hashmap() {
282 let _guard = crate::global_test_mutex_lock();
283
284 if let Ok(mut g) = idx().write() {
285 g.pkgs = vec![
286 OfficialPkg {
287 name: "ripgrep".to_string(),
288 repo: "extra".to_string(),
289 arch: "x86_64".to_string(),
290 version: "14.0.0".to_string(),
291 description: "Fast grep".to_string(),
292 },
293 OfficialPkg {
294 name: "vim".to_string(),
295 repo: "extra".to_string(),
296 arch: "x86_64".to_string(),
297 version: "9.0".to_string(),
298 description: "Text editor".to_string(),
299 },
300 ];
301 g.rebuild_name_index();
302 }
303
304 // Test exact case
305 let result = find_package_by_name("ripgrep");
306 assert!(result.is_some());
307 assert_eq!(result.as_ref().map(|p| p.name.as_str()), Some("ripgrep"));
308
309 // Test different case (HashMap uses lowercase)
310 let result_upper = find_package_by_name("RIPGREP");
311 assert!(result_upper.is_some());
312 assert_eq!(
313 result_upper.as_ref().map(|p| p.name.as_str()),
314 Some("ripgrep")
315 );
316
317 // Test non-existent package
318 let not_found = find_package_by_name("nonexistent");
319 assert!(not_found.is_none());
320 }
321}