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(|e| IndexError::Io(e))? {
328            let mut entry = entry.map_err(|e| IndexError::Io(e))?;
329            let path = entry.path().map_err(|e| IndexError::Io(e))?;
330
331            if path.to_string_lossy() == "index.plist" {
332                let mut xml = String::new();
333                entry
334                    .read_to_string(&mut xml)
335                    .map_err(|e| IndexError::Io(e))?;
336                return Self::parse_plist(&xml, repo);
337            }
338        }
339
340        Err(IndexError::Parse("index.plist not found in archive".into()))
341    }
342
343    /// Load packages from all configured repositories in parallel.
344    fn load_packages(&self) -> Result<Vec<PackageMeta>, IndexError> {
345        let results: Vec<_> = self
346            .repos
347            .par_iter()
348            .map(|&repo| Self::load_repo(repo))
349            .collect();
350
351        let mut packages = Vec::new();
352        for result in results {
353            match result {
354                Ok(pkgs) => packages.extend(pkgs),
355                Err(e) => {
356                    eprintln!("Warning: failed to load Void repo: {}", e);
357                }
358            }
359        }
360
361        Ok(packages)
362    }
363}
364
365impl PackageIndex for Void {
366    fn ecosystem(&self) -> &'static str {
367        "void"
368    }
369
370    fn display_name(&self) -> &'static str {
371        "Void Linux (xbps)"
372    }
373
374    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
375        let packages = self.load_packages()?;
376
377        packages
378            .into_iter()
379            .find(|p| p.name.eq_ignore_ascii_case(name))
380            .ok_or_else(|| IndexError::NotFound(name.to_string()))
381    }
382
383    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
384        let packages = self.load_packages()?;
385
386        let versions: Vec<_> = packages
387            .into_iter()
388            .filter(|p| p.name.eq_ignore_ascii_case(name))
389            .map(|p| VersionMeta {
390                version: p.version,
391                released: None,
392                yanked: false,
393            })
394            .collect();
395
396        if versions.is_empty() {
397            return Err(IndexError::NotFound(name.to_string()));
398        }
399
400        Ok(versions)
401    }
402
403    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
404        let packages = self.load_packages()?;
405        let query_lower = query.to_lowercase();
406
407        Ok(packages
408            .into_iter()
409            .filter(|p| {
410                p.name.to_lowercase().contains(&query_lower)
411                    || p.description
412                        .as_ref()
413                        .map(|d| d.to_lowercase().contains(&query_lower))
414                        .unwrap_or(false)
415            })
416            .collect())
417    }
418
419    fn supports_fetch_all(&self) -> bool {
420        true
421    }
422
423    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
424        self.load_packages()
425    }
426}