Skip to main content

normalize_package_index/index/
opensuse.rs

1//! openSUSE package index fetcher.
2//!
3//! Fetches package metadata from openSUSE repositories.
4//!
5//! ## API Strategy
6//! - **fetch**: Searches configured repos for package (returns first match)
7//! - **fetch_versions**: Returns all versions across configured repos
8//! - **search**: Filters configured repos
9//! - **fetch_all**: All packages from configured repos, tagged with source_repo in extra
10//!
11//! ## Configuration
12//! ```rust,ignore
13//! // All repos (default)
14//! let index = OpenSuse::all();
15//!
16//! // Specific repos
17//! let index = OpenSuse::with_repos(&[
18//!     OpenSuseRepo::TumbleweedOss,
19//!     OpenSuseRepo::Leap156Oss,
20//! ]);
21//!
22//! // Just Tumbleweed
23//! let index = OpenSuse::tumbleweed();
24//! ```
25
26use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
27use crate::cache;
28use rayon::prelude::*;
29use std::collections::HashMap;
30use std::time::Duration;
31
32/// Cache TTL for openSUSE package index (1 hour).
33const CACHE_TTL: Duration = Duration::from_secs(60 * 60);
34
35/// Available openSUSE repositories.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum OpenSuseRepo {
38    // === Official: Tumbleweed ===
39    /// Tumbleweed OSS (rolling release, open source)
40    TumbleweedOss,
41    /// Tumbleweed Non-OSS (rolling release, proprietary)
42    TumbleweedNonOss,
43    /// Tumbleweed Updates
44    TumbleweedUpdate,
45
46    // === Official: Leap 16.0 ===
47    /// Leap 16.0 OSS
48    Leap160Oss,
49    /// Leap 16.0 Non-OSS
50    Leap160NonOss,
51
52    // === Official: Leap 15.6 ===
53    /// Leap 15.6 OSS
54    Leap156Oss,
55    /// Leap 15.6 Non-OSS
56    Leap156NonOss,
57    /// Leap 15.6 Updates OSS
58    Leap156UpdateOss,
59    /// Leap 15.6 Updates Non-OSS
60    Leap156UpdateNonOss,
61    /// Leap 15.6 Backports
62    Leap156Backports,
63    /// Leap 15.6 SLE Updates
64    Leap156Sle,
65
66    // === Official: Source RPMs ===
67    /// Tumbleweed Source OSS
68    TumbleweedSrcOss,
69    /// Tumbleweed Source Non-OSS
70    TumbleweedSrcNonOss,
71    /// Leap 15.6 Source OSS
72    Leap156SrcOss,
73    /// Leap 15.6 Source Non-OSS
74    Leap156SrcNonOss,
75
76    // === Official: Debug ===
77    /// Tumbleweed Debug
78    TumbleweedDebug,
79    /// Leap 16.0 Debug
80    Leap160Debug,
81    /// Leap 15.6 Debug
82    Leap156Debug,
83    /// Leap 15.6 Debug Updates
84    Leap156DebugUpdate,
85
86    // === Factory (bleeding edge) ===
87    /// Factory OSS (development, becomes Tumbleweed)
88    FactoryOss,
89
90    // === Community: Games ===
91    /// Games for Tumbleweed
92    GamesTumbleweed,
93    /// Games for Leap 15.6
94    GamesLeap156,
95
96    // === Community: KDE ===
97    /// KDE Extra for Tumbleweed
98    KdeExtraTumbleweed,
99    /// KDE Frameworks for Tumbleweed
100    KdeFrameworksTumbleweed,
101    /// KDE Qt6 for Tumbleweed
102    KdeQt6Tumbleweed,
103
104    // === Community: GNOME ===
105    /// GNOME Apps for Leap 16.0
106    GnomeAppsLeap160,
107    /// GNOME Apps for Leap 15.6
108    GnomeAppsLeap156,
109    /// GNOME Factory
110    GnomeFactory,
111
112    // === Community: Desktop Environments ===
113    /// Xfce for Tumbleweed
114    XfceTumbleweed,
115    /// Xfce for Leap 16.0
116    XfceLeap160,
117    /// Xfce for Leap 15.6
118    XfceLeap156,
119
120    // === Community: Server/Tools ===
121    /// Mozilla (Firefox, Thunderbird) for Tumbleweed
122    MozillaTumbleweed,
123    /// Science packages for Tumbleweed
124    ScienceTumbleweed,
125    /// Wine for Tumbleweed
126    WineTumbleweed,
127    /// HTTP servers for Tumbleweed
128    ServerHttpTumbleweed,
129    /// Database servers for Tumbleweed
130    ServerDatabaseTumbleweed,
131}
132
133impl OpenSuseRepo {
134    /// Get repo identifier for caching.
135    fn id(&self) -> &'static str {
136        match self {
137            // Official
138            Self::TumbleweedOss => "tumbleweed-oss",
139            Self::TumbleweedNonOss => "tumbleweed-non-oss",
140            Self::TumbleweedUpdate => "tumbleweed-update",
141            Self::Leap160Oss => "leap-16.0-oss",
142            Self::Leap160NonOss => "leap-16.0-non-oss",
143            Self::Leap156Oss => "leap-15.6-oss",
144            Self::Leap156NonOss => "leap-15.6-non-oss",
145            Self::Leap156UpdateOss => "leap-15.6-update-oss",
146            Self::Leap156UpdateNonOss => "leap-15.6-update-non-oss",
147            Self::Leap156Backports => "leap-15.6-backports",
148            Self::Leap156Sle => "leap-15.6-sle",
149            // Source
150            Self::TumbleweedSrcOss => "tumbleweed-src-oss",
151            Self::TumbleweedSrcNonOss => "tumbleweed-src-non-oss",
152            Self::Leap156SrcOss => "leap-15.6-src-oss",
153            Self::Leap156SrcNonOss => "leap-15.6-src-non-oss",
154            // Debug
155            Self::TumbleweedDebug => "tumbleweed-debug",
156            Self::Leap160Debug => "leap-16.0-debug",
157            Self::Leap156Debug => "leap-15.6-debug",
158            Self::Leap156DebugUpdate => "leap-15.6-debug-update",
159            Self::FactoryOss => "factory-oss",
160            // Community
161            Self::GamesTumbleweed => "games-tumbleweed",
162            Self::GamesLeap156 => "games-leap-15.6",
163            Self::KdeExtraTumbleweed => "kde-extra-tumbleweed",
164            Self::KdeFrameworksTumbleweed => "kde-frameworks-tumbleweed",
165            Self::KdeQt6Tumbleweed => "kde-qt6-tumbleweed",
166            Self::GnomeAppsLeap160 => "gnome-apps-leap-16.0",
167            Self::GnomeAppsLeap156 => "gnome-apps-leap-15.6",
168            Self::GnomeFactory => "gnome-factory",
169            Self::XfceTumbleweed => "xfce-tumbleweed",
170            Self::XfceLeap160 => "xfce-leap-16.0",
171            Self::XfceLeap156 => "xfce-leap-15.6",
172            Self::MozillaTumbleweed => "mozilla-tumbleweed",
173            Self::ScienceTumbleweed => "science-tumbleweed",
174            Self::WineTumbleweed => "wine-tumbleweed",
175            Self::ServerHttpTumbleweed => "server-http-tumbleweed",
176            Self::ServerDatabaseTumbleweed => "server-database-tumbleweed",
177        }
178    }
179
180    /// Get repodata base URL.
181    fn base_url(&self) -> &'static str {
182        match self {
183            // Official main repos
184            Self::TumbleweedOss => "https://download.opensuse.org/tumbleweed/repo/oss/repodata",
185            Self::TumbleweedNonOss => {
186                "https://download.opensuse.org/tumbleweed/repo/non-oss/repodata"
187            }
188            Self::TumbleweedUpdate => "https://download.opensuse.org/update/tumbleweed/repodata",
189            Self::Leap160Oss => {
190                "https://download.opensuse.org/distribution/leap/16.0/repo/oss/repodata"
191            }
192            Self::Leap160NonOss => {
193                "https://download.opensuse.org/distribution/leap/16.0/repo/non-oss/repodata"
194            }
195            Self::Leap156Oss => {
196                "https://download.opensuse.org/distribution/leap/15.6/repo/oss/repodata"
197            }
198            Self::Leap156NonOss => {
199                "https://download.opensuse.org/distribution/leap/15.6/repo/non-oss/repodata"
200            }
201            Self::Leap156UpdateOss => "https://download.opensuse.org/update/leap/15.6/oss/repodata",
202            Self::Leap156UpdateNonOss => {
203                "https://download.opensuse.org/update/leap/15.6/non-oss/repodata"
204            }
205            Self::Leap156Backports => {
206                "https://download.opensuse.org/update/leap/15.6/backports/repodata"
207            }
208            Self::Leap156Sle => "https://download.opensuse.org/update/leap/15.6/sle/repodata",
209            // Source repos
210            Self::TumbleweedSrcOss => {
211                "https://download.opensuse.org/tumbleweed/repo/src-oss/repodata"
212            }
213            Self::TumbleweedSrcNonOss => {
214                "https://download.opensuse.org/tumbleweed/repo/src-non-oss/repodata"
215            }
216            Self::Leap156SrcOss => {
217                "https://download.opensuse.org/source/distribution/leap/15.6/repo/oss/repodata"
218            }
219            Self::Leap156SrcNonOss => {
220                "https://download.opensuse.org/source/distribution/leap/15.6/repo/non-oss/repodata"
221            }
222            // Debug repos
223            Self::TumbleweedDebug => {
224                "https://download.opensuse.org/debug/tumbleweed/repo/oss/repodata"
225            }
226            Self::Leap160Debug => {
227                "https://download.opensuse.org/debug/distribution/leap/16.0/repo/oss/repodata"
228            }
229            Self::Leap156Debug => {
230                "https://download.opensuse.org/debug/distribution/leap/15.6/repo/oss/repodata"
231            }
232            Self::Leap156DebugUpdate => {
233                "https://download.opensuse.org/debug/update/leap/15.6/oss/repodata"
234            }
235            Self::FactoryOss => "https://download.opensuse.org/factory/repo/oss/repodata",
236            // Community repos
237            Self::GamesTumbleweed => {
238                "https://download.opensuse.org/repositories/games/openSUSE_Tumbleweed/repodata"
239            }
240            Self::GamesLeap156 => "https://download.opensuse.org/repositories/games/15.6/repodata",
241            Self::KdeExtraTumbleweed => {
242                "https://download.opensuse.org/repositories/KDE:/Extra/openSUSE_Tumbleweed/repodata"
243            }
244            Self::KdeFrameworksTumbleweed => {
245                "https://download.opensuse.org/repositories/KDE:/Frameworks/openSUSE_Tumbleweed/repodata"
246            }
247            Self::KdeQt6Tumbleweed => {
248                "https://download.opensuse.org/repositories/KDE:/Qt6/openSUSE_Tumbleweed/repodata"
249            }
250            Self::GnomeAppsLeap160 => {
251                "https://download.opensuse.org/repositories/GNOME:/Apps/16.0/repodata"
252            }
253            Self::GnomeAppsLeap156 => {
254                "https://download.opensuse.org/repositories/GNOME:/Apps/15.6/repodata"
255            }
256            Self::GnomeFactory => {
257                "https://download.opensuse.org/repositories/GNOME:/Factory/openSUSE_Factory/repodata"
258            }
259            Self::XfceTumbleweed => {
260                "https://download.opensuse.org/repositories/X11:/xfce/openSUSE_Tumbleweed/repodata"
261            }
262            Self::XfceLeap160 => {
263                "https://download.opensuse.org/repositories/X11:/xfce/16.0/repodata"
264            }
265            Self::XfceLeap156 => {
266                "https://download.opensuse.org/repositories/X11:/xfce/15.6/repodata"
267            }
268            Self::MozillaTumbleweed => {
269                "https://download.opensuse.org/repositories/mozilla/openSUSE_Tumbleweed/repodata"
270            }
271            Self::ScienceTumbleweed => {
272                "https://download.opensuse.org/repositories/science/openSUSE_Tumbleweed/repodata"
273            }
274            Self::WineTumbleweed => {
275                "https://download.opensuse.org/repositories/Emulators:/Wine/openSUSE_Tumbleweed/repodata"
276            }
277            Self::ServerHttpTumbleweed => {
278                "https://download.opensuse.org/repositories/server:/http/openSUSE_Tumbleweed/repodata"
279            }
280            Self::ServerDatabaseTumbleweed => {
281                "https://download.opensuse.org/repositories/server:/database/openSUSE_Tumbleweed/repodata"
282            }
283        }
284    }
285
286    /// All available repos (official + community).
287    pub fn all() -> &'static [OpenSuseRepo] {
288        &[
289            // Official binary
290            Self::TumbleweedOss,
291            Self::TumbleweedNonOss,
292            Self::TumbleweedUpdate,
293            Self::Leap160Oss,
294            Self::Leap160NonOss,
295            Self::Leap156Oss,
296            Self::Leap156NonOss,
297            Self::Leap156UpdateOss,
298            Self::Leap156UpdateNonOss,
299            Self::Leap156Backports,
300            Self::Leap156Sle,
301            // Source
302            Self::TumbleweedSrcOss,
303            Self::TumbleweedSrcNonOss,
304            Self::Leap156SrcOss,
305            Self::Leap156SrcNonOss,
306            // Debug
307            Self::TumbleweedDebug,
308            Self::Leap160Debug,
309            Self::Leap156Debug,
310            Self::Leap156DebugUpdate,
311            // Factory
312            Self::FactoryOss,
313            // Community
314            Self::GamesTumbleweed,
315            Self::GamesLeap156,
316            Self::KdeExtraTumbleweed,
317            Self::KdeFrameworksTumbleweed,
318            Self::KdeQt6Tumbleweed,
319            Self::GnomeAppsLeap160,
320            Self::GnomeAppsLeap156,
321            Self::GnomeFactory,
322            Self::XfceTumbleweed,
323            Self::XfceLeap160,
324            Self::XfceLeap156,
325            Self::MozillaTumbleweed,
326            Self::ScienceTumbleweed,
327            Self::WineTumbleweed,
328            Self::ServerHttpTumbleweed,
329            Self::ServerDatabaseTumbleweed,
330        ]
331    }
332
333    /// Official repos only (no community).
334    pub fn official() -> &'static [OpenSuseRepo] {
335        &[
336            // Binary
337            Self::TumbleweedOss,
338            Self::TumbleweedNonOss,
339            Self::TumbleweedUpdate,
340            Self::Leap160Oss,
341            Self::Leap160NonOss,
342            Self::Leap156Oss,
343            Self::Leap156NonOss,
344            Self::Leap156UpdateOss,
345            Self::Leap156UpdateNonOss,
346            Self::Leap156Backports,
347            Self::Leap156Sle,
348            // Source
349            Self::TumbleweedSrcOss,
350            Self::TumbleweedSrcNonOss,
351            Self::Leap156SrcOss,
352            Self::Leap156SrcNonOss,
353            // Debug
354            Self::TumbleweedDebug,
355            Self::Leap160Debug,
356            Self::Leap156Debug,
357            Self::Leap156DebugUpdate,
358            // Factory
359            Self::FactoryOss,
360        ]
361    }
362
363    /// Binary repos only (no source or debug).
364    pub fn binary_only() -> &'static [OpenSuseRepo] {
365        &[
366            Self::TumbleweedOss,
367            Self::TumbleweedNonOss,
368            Self::TumbleweedUpdate,
369            Self::Leap160Oss,
370            Self::Leap160NonOss,
371            Self::Leap156Oss,
372            Self::Leap156NonOss,
373            Self::Leap156UpdateOss,
374            Self::Leap156UpdateNonOss,
375            Self::Leap156Backports,
376            Self::Leap156Sle,
377            Self::FactoryOss,
378        ]
379    }
380
381    /// Just Tumbleweed repos.
382    pub fn tumbleweed() -> &'static [OpenSuseRepo] {
383        &[
384            Self::TumbleweedOss,
385            Self::TumbleweedNonOss,
386            Self::TumbleweedUpdate,
387        ]
388    }
389
390    /// Just Leap 15.6 repos.
391    pub fn leap_15_6() -> &'static [OpenSuseRepo] {
392        &[
393            Self::Leap156Oss,
394            Self::Leap156NonOss,
395            Self::Leap156UpdateOss,
396            Self::Leap156UpdateNonOss,
397            Self::Leap156Backports,
398            Self::Leap156Sle,
399        ]
400    }
401
402    /// Just Leap 16.0 repos.
403    pub fn leap_16_0() -> &'static [OpenSuseRepo] {
404        &[Self::Leap160Oss, Self::Leap160NonOss]
405    }
406}
407
408/// openSUSE package index fetcher.
409pub struct OpenSuse {
410    repos: Vec<OpenSuseRepo>,
411}
412
413impl Default for OpenSuse {
414    fn default() -> Self {
415        Self::all()
416    }
417}
418
419impl OpenSuse {
420    /// Create fetcher for all repos.
421    pub fn all() -> Self {
422        Self {
423            repos: OpenSuseRepo::all().to_vec(),
424        }
425    }
426
427    /// Create fetcher for specific repos.
428    pub fn with_repos(repos: &[OpenSuseRepo]) -> Self {
429        Self {
430            repos: repos.to_vec(),
431        }
432    }
433
434    /// Create fetcher for Tumbleweed only.
435    pub fn tumbleweed() -> Self {
436        Self {
437            repos: OpenSuseRepo::tumbleweed().to_vec(),
438        }
439    }
440
441    /// Create fetcher for Leap 15.6 only.
442    pub fn leap_15_6() -> Self {
443        Self {
444            repos: OpenSuseRepo::leap_15_6().to_vec(),
445        }
446    }
447
448    /// Create fetcher for Leap 16.0 only.
449    pub fn leap_16_0() -> Self {
450        Self {
451            repos: OpenSuseRepo::leap_16_0().to_vec(),
452        }
453    }
454
455    /// Find primary.xml.zst URL from repomd.xml.
456    fn find_primary_url(repo: OpenSuseRepo) -> Result<String, IndexError> {
457        let repomd_url = format!("{}/repomd.xml", repo.base_url());
458        let cache_key = format!("repomd-{}", repo.id());
459        let (data, _) = cache::fetch_with_cache("opensuse", &cache_key, &repomd_url, CACHE_TTL)
460            .map_err(IndexError::Network)?;
461
462        let xml = String::from_utf8_lossy(&data);
463
464        // Parse repomd.xml to find primary.xml.zst location
465        for line in xml.lines() {
466            if line.contains("primary.xml.zst") || line.contains("primary.xml.gz") {
467                if let Some(start) = line.find("href=\"") {
468                    let rest = &line[start + 6..];
469                    if let Some(end) = rest.find('"') {
470                        let href = &rest[..end];
471                        let base = repo.base_url().trim_end_matches("/repodata");
472                        return Ok(format!("{}/{}", base, href));
473                    }
474                }
475            }
476        }
477
478        Err(IndexError::Parse(format!(
479            "primary.xml not found in repomd.xml for {}",
480            repo.id()
481        )))
482    }
483
484    /// Parse primary.xml to extract packages, tagging with source repo.
485    fn parse_primary(xml: &str, repo_id: &str) -> Vec<PackageMeta> {
486        let mut packages = Vec::new();
487        let mut in_package = false;
488        let mut name = String::new();
489        let mut version = String::new();
490        let mut release = String::new();
491        let mut summary = String::new();
492        let mut url = String::new();
493        let mut license = String::new();
494
495        for line in xml.lines() {
496            let line = line.trim();
497
498            if line.starts_with("<package type=\"rpm\">") {
499                in_package = true;
500                name.clear();
501                version.clear();
502                release.clear();
503                summary.clear();
504                url.clear();
505                license.clear();
506            } else if line == "</package>" && in_package {
507                if !name.is_empty() {
508                    let mut extra = HashMap::new();
509                    extra.insert(
510                        "source_repo".to_string(),
511                        serde_json::Value::String(repo_id.to_string()),
512                    );
513
514                    // Include release in version if present
515                    let full_version = if release.is_empty() {
516                        version.clone()
517                    } else {
518                        format!("{}-{}", version, release)
519                    };
520
521                    packages.push(PackageMeta {
522                        name: name.clone(),
523                        version: full_version,
524                        description: if summary.is_empty() {
525                            None
526                        } else {
527                            Some(summary.clone())
528                        },
529                        homepage: if url.is_empty() {
530                            None
531                        } else {
532                            Some(url.clone())
533                        },
534                        repository: Some(
535                            "https://build.opensuse.org/project/show/openSUSE:Factory".to_string(),
536                        ),
537                        license: if license.is_empty() {
538                            None
539                        } else {
540                            Some(license.clone())
541                        },
542                        binaries: Vec::new(),
543                        keywords: Vec::new(),
544                        maintainers: Vec::new(),
545                        published: None,
546                        downloads: None,
547                        archive_url: None,
548                        checksum: None,
549                        extra,
550                    });
551                }
552                in_package = false;
553            } else if in_package {
554                if line.starts_with("<name>") && line.ends_with("</name>") {
555                    name = line[6..line.len() - 7].to_string();
556                } else if line.starts_with("<summary>") && line.ends_with("</summary>") {
557                    summary = line[9..line.len() - 10].to_string();
558                } else if line.starts_with("<url>") && line.ends_with("</url>") {
559                    url = line[5..line.len() - 6].to_string();
560                } else if line.starts_with("<rpm:license>") && line.ends_with("</rpm:license>") {
561                    license = line[13..line.len() - 14].to_string();
562                } else if line.starts_with("<version ") {
563                    if let Some(ver_start) = line.find("ver=\"") {
564                        let rest = &line[ver_start + 5..];
565                        if let Some(ver_end) = rest.find('"') {
566                            version = rest[..ver_end].to_string();
567                        }
568                    }
569                    if let Some(rel_start) = line.find("rel=\"") {
570                        let rest = &line[rel_start + 5..];
571                        if let Some(rel_end) = rest.find('"') {
572                            release = rest[..rel_end].to_string();
573                        }
574                    }
575                }
576            }
577        }
578
579        packages
580    }
581
582    /// Load packages from a single repo.
583    fn load_repo(repo: OpenSuseRepo) -> Result<Vec<PackageMeta>, IndexError> {
584        let primary_url = Self::find_primary_url(repo)?;
585        let cache_key = format!("primary-{}", repo.id());
586
587        let (data, _was_cached) =
588            cache::fetch_with_cache("opensuse", &cache_key, &primary_url, CACHE_TTL)
589                .map_err(IndexError::Network)?;
590
591        // Decompress - try zstd first, fall back to gzip
592        let decompressed = if primary_url.ends_with(".zst") {
593            zstd::decode_all(std::io::Cursor::new(&data))
594                .map_err(|e| IndexError::Decompress(e.to_string()))?
595        } else {
596            use flate2::read::GzDecoder;
597            use std::io::Read;
598            let mut decoder = GzDecoder::new(&data[..]);
599            let mut decompressed = Vec::new();
600            decoder
601                .read_to_end(&mut decompressed)
602                .map_err(|e| IndexError::Decompress(e.to_string()))?;
603            decompressed
604        };
605
606        let xml = String::from_utf8_lossy(&decompressed);
607        Ok(Self::parse_primary(&xml, repo.id()))
608    }
609
610    /// Load packages from configured repos in parallel.
611    fn load_packages(&self) -> Result<Vec<PackageMeta>, IndexError> {
612        let results: Vec<_> = self
613            .repos
614            .par_iter()
615            .map(|&repo| Self::load_repo(repo))
616            .collect();
617
618        let mut all_packages = Vec::new();
619        for (repo, result) in self.repos.iter().zip(results) {
620            match result {
621                Ok(packages) => {
622                    all_packages.extend(packages);
623                }
624                Err(e) => {
625                    eprintln!("Warning: failed to load openSUSE repo {}: {}", repo.id(), e);
626                }
627            }
628        }
629
630        if all_packages.is_empty() {
631            return Err(IndexError::Network(
632                "failed to load any openSUSE repos".into(),
633            ));
634        }
635
636        Ok(all_packages)
637    }
638}
639
640impl PackageIndex for OpenSuse {
641    fn ecosystem(&self) -> &'static str {
642        "opensuse"
643    }
644
645    fn display_name(&self) -> &'static str {
646        "openSUSE (zypper)"
647    }
648
649    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
650        let packages = self.load_packages()?;
651
652        packages
653            .into_iter()
654            .find(|p| p.name.eq_ignore_ascii_case(name))
655            .ok_or_else(|| IndexError::NotFound(name.to_string()))
656    }
657
658    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
659        let packages = self.load_packages()?;
660        let name_lower = name.to_lowercase();
661
662        let versions: Vec<VersionMeta> = packages
663            .into_iter()
664            .filter(|p| p.name.to_lowercase() == name_lower)
665            .map(|p| VersionMeta {
666                version: p.version,
667                released: None,
668                yanked: false,
669            })
670            .collect();
671
672        if versions.is_empty() {
673            return Err(IndexError::NotFound(name.to_string()));
674        }
675
676        Ok(versions)
677    }
678
679    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
680        let packages = self.load_packages()?;
681        let query_lower = query.to_lowercase();
682
683        Ok(packages
684            .into_iter()
685            .filter(|p| {
686                p.name.to_lowercase().contains(&query_lower)
687                    || p.description
688                        .as_ref()
689                        .map(|d| d.to_lowercase().contains(&query_lower))
690                        .unwrap_or(false)
691            })
692            .collect())
693    }
694
695    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
696        self.load_packages()
697    }
698}