Skip to main content

normalize_package_index/index/
hex.rs

1//! Hex package index fetcher (Elixir/Erlang).
2//!
3//! Fetches package metadata from hex.pm.
4//!
5//! ## API Strategy
6//! - **fetch**: `hex.pm/api/packages/{name}` - Official Hex JSON API
7//! - **fetch_versions**: Same API, extracts releases array
8//! - **search**: `hex.pm/api/packages?search=` - Hex search
9//! - **fetch_all**: `hex.pm/api/packages` with pagination
10
11use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
12
13/// Hex package index fetcher.
14pub struct Hex;
15
16impl Hex {
17    /// Hex.pm API.
18    const HEX_API: &'static str = "https://hex.pm/api";
19}
20
21impl PackageIndex for Hex {
22    fn ecosystem(&self) -> &'static str {
23        "hex"
24    }
25
26    fn display_name(&self) -> &'static str {
27        "Hex (Elixir/Erlang)"
28    }
29
30    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
31        let url = format!("{}/packages/{}", Self::HEX_API, name);
32        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
33
34        let meta = &response["meta"];
35        let latest_release = response["releases"].as_array().and_then(|r| r.first());
36
37        Ok(PackageMeta {
38            name: response["name"].as_str().unwrap_or(name).to_string(),
39            version: latest_release
40                .and_then(|r| r["version"].as_str())
41                .unwrap_or("unknown")
42                .to_string(),
43            description: meta["description"].as_str().map(String::from),
44            homepage: response["html_url"].as_str().map(String::from),
45            repository: meta["links"]["GitHub"]
46                .as_str()
47                .or_else(|| meta["links"]["Repository"].as_str())
48                .or_else(|| meta["links"]["Source"].as_str())
49                .map(String::from),
50            license: meta["licenses"]
51                .as_array()
52                .and_then(|l| l.first())
53                .and_then(|l| l.as_str())
54                .map(String::from),
55            binaries: Vec::new(),
56            keywords: Vec::new(), // Hex doesn't have keywords
57            maintainers: meta["maintainers"]
58                .as_array()
59                .map(|m| {
60                    m.iter()
61                        .filter_map(|maint| maint["username"].as_str().map(String::from))
62                        .collect()
63                })
64                .unwrap_or_default(),
65            published: latest_release
66                .and_then(|r| r["inserted_at"].as_str())
67                .map(String::from),
68            downloads: response["downloads"]["all"].as_u64(),
69            archive_url: latest_release
70                .and_then(|r| r["url"].as_str())
71                .map(String::from),
72            checksum: latest_release
73                .and_then(|r| r["checksum"].as_str())
74                .map(|h| format!("sha256:{}", h)),
75            extra: Default::default(),
76        })
77    }
78
79    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
80        let url = format!("{}/packages/{}", Self::HEX_API, name);
81        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
82
83        let releases = response["releases"]
84            .as_array()
85            .ok_or_else(|| IndexError::Parse("missing releases".into()))?;
86
87        Ok(releases
88            .iter()
89            .filter_map(|r| {
90                Some(VersionMeta {
91                    version: r["version"].as_str()?.to_string(),
92                    released: r["inserted_at"].as_str().map(String::from),
93                    yanked: r["retired"].as_bool().unwrap_or(false),
94                })
95            })
96            .collect())
97    }
98
99    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
100        let url = format!("{}/packages?search={}", Self::HEX_API, query);
101        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
102
103        let packages = response
104            .as_array()
105            .ok_or_else(|| IndexError::Parse("expected array".into()))?;
106
107        Ok(packages
108            .iter()
109            .filter_map(|pkg| {
110                let meta = &pkg["meta"];
111                let latest_release = pkg["releases"].as_array().and_then(|r| r.first());
112                Some(PackageMeta {
113                    name: pkg["name"].as_str()?.to_string(),
114                    version: latest_release
115                        .and_then(|r| r["version"].as_str())
116                        .unwrap_or("unknown")
117                        .to_string(),
118                    description: meta["description"].as_str().map(String::from),
119                    homepage: pkg["html_url"].as_str().map(String::from),
120                    repository: meta["links"]["GitHub"]
121                        .as_str()
122                        .or_else(|| meta["links"]["Repository"].as_str())
123                        .map(String::from),
124                    license: meta["licenses"]
125                        .as_array()
126                        .and_then(|l| l.first())
127                        .and_then(|l| l.as_str())
128                        .map(String::from),
129                    binaries: Vec::new(),
130                    keywords: Vec::new(),
131                    maintainers: meta["maintainers"]
132                        .as_array()
133                        .map(|m| {
134                            m.iter()
135                                .filter_map(|maint| maint["username"].as_str().map(String::from))
136                                .collect()
137                        })
138                        .unwrap_or_default(),
139                    published: latest_release
140                        .and_then(|r| r["inserted_at"].as_str())
141                        .map(String::from),
142                    downloads: pkg["downloads"]["all"].as_u64(),
143                    archive_url: latest_release
144                        .and_then(|r| r["url"].as_str())
145                        .map(String::from),
146                    checksum: latest_release
147                        .and_then(|r| r["checksum"].as_str())
148                        .map(|h| format!("sha256:{}", h)),
149                    extra: Default::default(),
150                })
151            })
152            .collect())
153    }
154}