Skip to main content

normalize_package_index/index/
cran.rs

1//! CRAN package index fetcher (R).
2//!
3//! Fetches package metadata from CRAN (Comprehensive R Archive Network).
4//! Uses the crandb API for JSON access.
5//!
6//! ## API Strategy
7//! - **fetch**: `crandb.r-pkg.org/{name}` - crandb JSON API
8//! - **fetch_versions**: `crandb.r-pkg.org/{name}/all` - all versions
9//! - **search**: `crandb.r-pkg.org/-/search?q=` - crandb search endpoint
10//! - **fetch_all**: Not supported (no bulk endpoint)
11
12use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
13
14/// CRAN package index fetcher.
15pub struct Cran;
16
17impl Cran {
18    /// crandb API base (provides JSON API for CRAN).
19    const API_BASE: &'static str = "https://crandb.r-pkg.org";
20
21    /// CRAN mirror for downloads.
22    const CRAN_MIRROR: &'static str = "https://cran.r-project.org";
23}
24
25impl PackageIndex for Cran {
26    fn ecosystem(&self) -> &'static str {
27        "cran"
28    }
29
30    fn display_name(&self) -> &'static str {
31        "CRAN (R)"
32    }
33
34    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
35        let url = format!("{}/{}", Self::API_BASE, name);
36        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
37
38        // Check for error response
39        if response["error"].is_string() {
40            return Err(IndexError::NotFound(name.to_string()));
41        }
42
43        let version = response["Version"]
44            .as_str()
45            .unwrap_or("unknown")
46            .to_string();
47
48        Ok(PackageMeta {
49            name: response["Package"].as_str().unwrap_or(name).to_string(),
50            version: version.clone(),
51            description: response["Title"]
52                .as_str()
53                .or_else(|| response["Description"].as_str())
54                .map(String::from),
55            homepage: response["URL"]
56                .as_str()
57                .and_then(|urls| urls.split(',').next())
58                .map(|s| s.trim().to_string()),
59            repository: response["BugReports"]
60                .as_str()
61                .filter(|s| s.contains("github.com") || s.contains("gitlab.com"))
62                .map(String::from),
63            license: response["License"].as_str().map(String::from),
64            binaries: Vec::new(),
65            maintainers: response["Maintainer"]
66                .as_str()
67                .map(|m| vec![m.to_string()])
68                .unwrap_or_default(),
69            keywords: Vec::new(),
70            published: None,
71            downloads: None,
72            archive_url: Some(format!(
73                "{}/src/contrib/{}_{}.tar.gz",
74                Self::CRAN_MIRROR,
75                name,
76                version
77            )),
78            checksum: None,
79            extra: Default::default(),
80        })
81    }
82
83    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
84        // crandb /all endpoint returns all versions
85        let url = format!("{}/{}/all", Self::API_BASE, name);
86        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
87
88        // Check for error response
89        if response["error"].is_string() {
90            return Err(IndexError::NotFound(name.to_string()));
91        }
92
93        let versions = response["versions"]
94            .as_object()
95            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
96
97        let mut result: Vec<VersionMeta> = versions
98            .iter()
99            .map(|(version, data)| VersionMeta {
100                version: version.clone(),
101                released: data["crandb_file_date"].as_str().map(String::from),
102                yanked: false, // CRAN doesn't have yanked concept
103            })
104            .collect();
105
106        // Sort by version descending
107        result.sort_by(|a, b| version_compare(&b.version, &a.version));
108
109        Ok(result)
110    }
111
112    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
113        // crandb search endpoint
114        let url = format!(
115            "{}/-/search?q={}&size=50",
116            Self::API_BASE,
117            urlencoding::encode(query)
118        );
119        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
120
121        let packages = response
122            .as_array()
123            .ok_or_else(|| IndexError::Parse("expected array".into()))?;
124
125        Ok(packages
126            .iter()
127            .filter_map(|pkg| {
128                Some(PackageMeta {
129                    name: pkg["Package"].as_str()?.to_string(),
130                    version: pkg["Version"].as_str().unwrap_or("unknown").to_string(),
131                    description: pkg["Title"].as_str().map(String::from),
132                    homepage: None,
133                    repository: None,
134                    license: pkg["License"].as_str().map(String::from),
135                    binaries: Vec::new(),
136                    keywords: Vec::new(),
137                    maintainers: Vec::new(),
138                    published: None,
139                    downloads: None,
140                    archive_url: None,
141                    checksum: None,
142                    extra: Default::default(),
143                })
144            })
145            .collect())
146    }
147}
148
149/// Simple version comparison (handles R-style versions like "1.2-3").
150fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
151    let parse = |s: &str| -> Vec<u32> {
152        s.split(|c: char| !c.is_ascii_digit())
153            .filter_map(|p| p.parse().ok())
154            .collect()
155    };
156    parse(a).cmp(&parse(b))
157}