Skip to main content

normalize_package_index/index/
void.rs

1//! Void Linux package index fetcher (xbps).
2//!
3//! Fetches package metadata from Void Linux repositories.
4//!
5//! ## API Strategy
6//! - **fetch**: Searches cached `repo-default.voidlinux.org/.../x86_64-repodata` (zstd tar + XML plist)
7//! - **fetch_versions**: Loads from all configured repos
8//! - **search**: Filters cached repodata
9//! - **fetch_all**: Full repodata (cached 1 hour, ~20MB uncompressed per repo)
10//!
11//! ## Multi-repo Support
12//! ```rust,ignore
13//! use normalize_packages::index::void::{Void, VoidRepo};
14//!
15//! // All repos (default)
16//! let all = Void::all();
17//!
18//! // x86_64 glibc only
19//! let x64 = Void::with_repos(&[VoidRepo::X86_64, VoidRepo::X86_64Nonfree]);
20//!
21//! // musl variants
22//! let musl = Void::musl();
23//! ```
24
25use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
26use crate::cache;
27use rayon::prelude::*;
28use std::collections::HashMap;
29use std::io::Read;
30use std::time::Duration;
31
32/// Cache TTL for Void package index (1 hour).
33const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
34
35/// Void Linux repository base URL.
36const VOID_MIRROR: &str = "https://repo-default.voidlinux.org/current";
37
38/// Available Void Linux repositories.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum VoidRepo {
41    // === x86_64 glibc ===
42    /// x86_64 glibc main repository
43    X86_64,
44    /// x86_64 glibc nonfree repository
45    X86_64Nonfree,
46
47    // === x86_64 musl ===
48    /// x86_64 musl main repository
49    X86_64Musl,
50    /// x86_64 musl nonfree repository
51    X86_64MuslNonfree,
52
53    // === aarch64 glibc ===
54    /// aarch64 glibc main repository
55    Aarch64,
56    /// aarch64 glibc nonfree repository
57    Aarch64Nonfree,
58
59    // === aarch64 musl ===
60    /// aarch64 musl main repository
61    Aarch64Musl,
62    /// aarch64 musl nonfree repository
63    Aarch64MuslNonfree,
64}
65
66impl VoidRepo {
67    /// Get the repository URL.
68    fn url(&self) -> String {
69        match self {
70            Self::X86_64 => format!("{}/x86_64-repodata", VOID_MIRROR),
71            Self::X86_64Nonfree => format!("{}/nonfree/x86_64-repodata", VOID_MIRROR),
72            Self::X86_64Musl => format!("{}/musl/x86_64-repodata", VOID_MIRROR),
73            Self::X86_64MuslNonfree => format!("{}/musl/nonfree/x86_64-repodata", VOID_MIRROR),
74            Self::Aarch64 => format!("{}/aarch64-repodata", VOID_MIRROR),
75            Self::Aarch64Nonfree => format!("{}/nonfree/aarch64-repodata", VOID_MIRROR),
76            Self::Aarch64Musl => format!("{}/musl/aarch64-repodata", VOID_MIRROR),
77            Self::Aarch64MuslNonfree => format!("{}/musl/nonfree/aarch64-repodata", VOID_MIRROR),
78        }
79    }
80
81    /// Get the repository name for tagging.
82    pub fn name(&self) -> &'static str {
83        match self {
84            Self::X86_64 => "x86_64",
85            Self::X86_64Nonfree => "x86_64-nonfree",
86            Self::X86_64Musl => "x86_64-musl",
87            Self::X86_64MuslNonfree => "x86_64-musl-nonfree",
88            Self::Aarch64 => "aarch64",
89            Self::Aarch64Nonfree => "aarch64-nonfree",
90            Self::Aarch64Musl => "aarch64-musl",
91            Self::Aarch64MuslNonfree => "aarch64-musl-nonfree",
92        }
93    }
94
95    /// All available repositories.
96    pub fn all() -> &'static [VoidRepo] {
97        &[
98            Self::X86_64,
99            Self::X86_64Nonfree,
100            Self::X86_64Musl,
101            Self::X86_64MuslNonfree,
102            Self::Aarch64,
103            Self::Aarch64Nonfree,
104            Self::Aarch64Musl,
105            Self::Aarch64MuslNonfree,
106        ]
107    }
108
109    /// x86_64 glibc repositories.
110    pub fn x86_64() -> &'static [VoidRepo] {
111        &[Self::X86_64, Self::X86_64Nonfree]
112    }
113
114    /// x86_64 musl repositories.
115    pub fn x86_64_musl() -> &'static [VoidRepo] {
116        &[Self::X86_64Musl, Self::X86_64MuslNonfree]
117    }
118
119    /// All musl repositories.
120    pub fn musl() -> &'static [VoidRepo] {
121        &[
122            Self::X86_64Musl,
123            Self::X86_64MuslNonfree,
124            Self::Aarch64Musl,
125            Self::Aarch64MuslNonfree,
126        ]
127    }
128
129    /// All glibc repositories.
130    pub fn glibc() -> &'static [VoidRepo] {
131        &[
132            Self::X86_64,
133            Self::X86_64Nonfree,
134            Self::Aarch64,
135            Self::Aarch64Nonfree,
136        ]
137    }
138
139    /// Free (non-proprietary) repositories only.
140    pub fn free() -> &'static [VoidRepo] {
141        &[
142            Self::X86_64,
143            Self::X86_64Musl,
144            Self::Aarch64,
145            Self::Aarch64Musl,
146        ]
147    }
148}
149
150/// Void Linux package index fetcher with configurable repositories.
151pub struct Void {
152    repos: Vec<VoidRepo>,
153}
154
155impl Void {
156    /// Create a fetcher with all repositories.
157    pub fn all() -> Self {
158        Self {
159            repos: VoidRepo::all().to_vec(),
160        }
161    }
162
163    /// Create a fetcher with x86_64 glibc repositories.
164    pub fn x86_64() -> Self {
165        Self {
166            repos: VoidRepo::x86_64().to_vec(),
167        }
168    }
169
170    /// Create a fetcher with x86_64 musl repositories.
171    pub fn x86_64_musl() -> Self {
172        Self {
173            repos: VoidRepo::x86_64_musl().to_vec(),
174        }
175    }
176
177    /// Create a fetcher with all musl repositories.
178    pub fn musl() -> Self {
179        Self {
180            repos: VoidRepo::musl().to_vec(),
181        }
182    }
183
184    /// Create a fetcher with all glibc repositories.
185    pub fn glibc() -> Self {
186        Self {
187            repos: VoidRepo::glibc().to_vec(),
188        }
189    }
190
191    /// Create a fetcher with free repositories only.
192    pub fn free() -> Self {
193        Self {
194            repos: VoidRepo::free().to_vec(),
195        }
196    }
197
198    /// Create a fetcher with custom repository selection.
199    pub fn with_repos(repos: &[VoidRepo]) -> Self {
200        Self {
201            repos: repos.to_vec(),
202        }
203    }
204
205    /// Parse plist XML into packages.
206    fn parse_plist(xml: &str, repo: VoidRepo) -> Result<Vec<PackageMeta>, IndexError> {
207        let mut packages = Vec::new();
208        let mut current_name: Option<String> = None;
209        let mut in_package = false;
210        let mut current_field: Option<String> = None;
211
212        let mut version = String::new();
213        let mut homepage = String::new();
214        let mut description = String::new();
215        let mut license = String::new();
216        let mut maintainer = String::new();
217
218        for line in xml.lines() {
219            let line = line.trim();
220
221            if line.starts_with("<key>") && line.ends_with("</key>") {
222                let key = &line[5..line.len() - 6];
223                if !in_package {
224                    current_name = Some(key.to_string());
225                    in_package = false;
226                    version.clear();
227                    homepage.clear();
228                    description.clear();
229                    license.clear();
230                    maintainer.clear();
231                } else {
232                    current_field = Some(key.to_string());
233                }
234            } else if line == "<dict>" && current_name.is_some() && !in_package {
235                in_package = true;
236            } else if line == "</dict>" && in_package {
237                if let Some(name) = current_name.take() {
238                    let (pkg_name, ver) = if version.contains('-') {
239                        let parts: Vec<&str> = version.rsplitn(2, '-').collect();
240                        if parts.len() == 2 {
241                            (parts[1].to_string(), parts[0].to_string())
242                        } else {
243                            (name.clone(), version.clone())
244                        }
245                    } else {
246                        (name.clone(), version.clone())
247                    };
248
249                    let mut extra = HashMap::new();
250                    extra.insert(
251                        "source_repo".to_string(),
252                        serde_json::Value::String(repo.name().to_string()),
253                    );
254
255                    packages.push(PackageMeta {
256                        name: pkg_name,
257                        version: ver,
258                        description: if description.is_empty() {
259                            None
260                        } else {
261                            Some(description.clone())
262                        },
263                        homepage: if homepage.is_empty() {
264                            None
265                        } else {
266                            Some(homepage.clone())
267                        },
268                        repository: Some("https://github.com/void-linux/void-packages".to_string()),
269                        license: if license.is_empty() {
270                            None
271                        } else {
272                            Some(license.clone())
273                        },
274                        maintainers: if maintainer.is_empty() {
275                            Vec::new()
276                        } else {
277                            vec![maintainer.clone()]
278                        },
279                        binaries: Vec::new(),
280                        keywords: Vec::new(),
281                        published: None,
282                        downloads: None,
283                        archive_url: None,
284                        checksum: None,
285                        extra,
286                    });
287                }
288                in_package = false;
289            } else if line.starts_with("<string>") && line.ends_with("</string>") {
290                let value = &line[8..line.len() - 9];
291                if let Some(field) = &current_field {
292                    match field.as_str() {
293                        "pkgver" => version = value.to_string(),
294                        "homepage" => homepage = value.to_string(),
295                        "short_desc" => description = value.to_string(),
296                        "license" => license = value.to_string(),
297                        "maintainer" => maintainer = value.to_string(),
298                        _ => {}
299                    }
300                }
301                current_field = None;
302            }
303        }
304
305        Ok(packages)
306    }
307
308    /// Load packages from a single repository.
309    fn load_repo(repo: VoidRepo) -> Result<Vec<PackageMeta>, IndexError> {
310        let url = repo.url();
311
312        let (data, _was_cached) = cache::fetch_with_cache(
313            "void",
314            &format!("repodata-{}", repo.name()),
315            &url,
316            CACHE_TTL,
317        )
318        .map_err(IndexError::Network)?;
319
320        // Decompress zstd
321        let decompressed = zstd::decode_all(std::io::Cursor::new(&data))
322            .map_err(|e| IndexError::Decompress(e.to_string()))?;
323
324        // Extract tar
325        let mut archive = tar::Archive::new(std::io::Cursor::new(decompressed));
326
327        for entry in archive.entries().map_err(IndexError::Io)? {
328            let mut entry = entry.map_err(IndexError::Io)?;
329            let path = entry.path().map_err(IndexError::Io)?;
330
331            if path.to_string_lossy() == "index.plist" {
332                let mut xml = String::new();
333                entry.read_to_string(&mut xml).map_err(IndexError::Io)?;
334                return Self::parse_plist(&xml, repo);
335            }
336        }
337
338        Err(IndexError::Parse("index.plist not found in archive".into()))
339    }
340
341    /// Load packages from all configured repositories in parallel.
342    fn load_packages(&self) -> Result<Vec<PackageMeta>, IndexError> {
343        let results: Vec<_> = self
344            .repos
345            .par_iter()
346            .map(|&repo| Self::load_repo(repo))
347            .collect();
348
349        let mut packages = Vec::new();
350        for result in results {
351            match result {
352                Ok(pkgs) => packages.extend(pkgs),
353                Err(e) => {
354                    tracing::warn!("failed to load Void repo: {}", e);
355                }
356            }
357        }
358
359        Ok(packages)
360    }
361}
362
363impl PackageIndex for Void {
364    fn ecosystem(&self) -> &'static str {
365        "void"
366    }
367
368    fn display_name(&self) -> &'static str {
369        "Void Linux (xbps)"
370    }
371
372    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
373        let packages = self.load_packages()?;
374
375        packages
376            .into_iter()
377            .find(|p| p.name.eq_ignore_ascii_case(name))
378            .ok_or_else(|| IndexError::NotFound(name.to_string()))
379    }
380
381    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
382        let packages = self.load_packages()?;
383
384        let versions: Vec<_> = packages
385            .into_iter()
386            .filter(|p| p.name.eq_ignore_ascii_case(name))
387            .map(|p| VersionMeta {
388                version: p.version,
389                released: None,
390                yanked: false,
391            })
392            .collect();
393
394        if versions.is_empty() {
395            return Err(IndexError::NotFound(name.to_string()));
396        }
397
398        Ok(versions)
399    }
400
401    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
402        let packages = self.load_packages()?;
403        let query_lower = query.to_lowercase();
404
405        Ok(packages
406            .into_iter()
407            .filter(|p| {
408                p.name.to_lowercase().contains(&query_lower)
409                    || p.description
410                        .as_ref()
411                        .map(|d| d.to_lowercase().contains(&query_lower))
412                        .unwrap_or(false)
413            })
414            .collect())
415    }
416
417    fn supports_fetch_all(&self) -> bool {
418        true
419    }
420
421    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
422        self.load_packages()
423    }
424}