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}