Skip to main content

normalize_package_index/index/
hackage.rs

1//! Hackage package index fetcher (Haskell).
2//!
3//! Fetches package metadata from hackage.haskell.org.
4//!
5//! ## API Strategy
6//! - **fetch**: `hackage.haskell.org/package/{name}/preferred` - Official JSON API
7//! - **fetch_versions**: `hackage.haskell.org/package/{name}` - version list
8//! - **search**: `hackage.haskell.org/packages/search?terms=` - Hackage search
9//! - **fetch_all**: Not supported (no bulk endpoint)
10
11use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
12
13/// Hackage package index fetcher.
14pub struct Hackage;
15
16impl Hackage {
17    /// Hackage API base.
18    const API_BASE: &'static str = "https://hackage.haskell.org";
19}
20
21impl PackageIndex for Hackage {
22    fn ecosystem(&self) -> &'static str {
23        "hackage"
24    }
25
26    fn display_name(&self) -> &'static str {
27        "Hackage (Haskell)"
28    }
29
30    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
31        // Hackage package info endpoint
32        let url = format!("{}/package/{}", Self::API_BASE, name);
33        let response: serde_json::Value = ureq::get(&url)
34            .set("Accept", "application/json")
35            .call()?
36            .into_json()?;
37
38        // Get latest version from versions endpoint
39        let versions_url = format!("{}/package/{}/preferred", Self::API_BASE, name);
40        let versions: serde_json::Value = ureq::get(&versions_url)
41            .set("Accept", "application/json")
42            .call()
43            .ok()
44            .and_then(|r| r.into_json().ok())
45            .unwrap_or_default();
46
47        let latest_version = versions["normal-version"]
48            .as_array()
49            .and_then(|v| v.first())
50            .and_then(|v| v.as_str())
51            .unwrap_or("unknown");
52
53        Ok(PackageMeta {
54            name: response["packageName"].as_str().unwrap_or(name).to_string(),
55            version: latest_version.to_string(),
56            description: response["packageDescription"].as_str().map(String::from),
57            homepage: response["packageHomepage"].as_str().map(String::from),
58            repository: response["packageSourceRepository"]
59                .as_str()
60                .map(String::from),
61            license: response["license"].as_str().map(String::from),
62            binaries: Vec::new(),
63            keywords: response["category"]
64                .as_str()
65                .map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
66                .unwrap_or_default(),
67            maintainers: {
68                let mut m = Vec::new();
69                if let Some(author) = response["author"].as_str()
70                    && !author.is_empty()
71                {
72                    m.push(author.to_string());
73                }
74                if let Some(maintainer) = response["maintainer"].as_str()
75                    && !maintainer.is_empty()
76                    && !m.contains(&maintainer.to_string())
77                {
78                    m.push(maintainer.to_string());
79                }
80                m
81            },
82            published: None, // Hackage doesn't expose upload time in this endpoint
83            downloads: response["downloads"].as_u64(),
84            archive_url: Some(format!(
85                "{}/package/{}-{}/{}-{}.tar.gz",
86                Self::API_BASE,
87                name,
88                latest_version,
89                name,
90                latest_version
91            )),
92            checksum: None, // Would need to download .cabal file
93            extra: Default::default(),
94        })
95    }
96
97    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
98        let url = format!("{}/package/{}/preferred", Self::API_BASE, name);
99        let response: serde_json::Value = ureq::get(&url)
100            .set("Accept", "application/json")
101            .call()?
102            .into_json()?;
103
104        let normal = response["normal-version"]
105            .as_array()
106            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
107
108        let deprecated = response["deprecated-version"]
109            .as_array()
110            .map(|arr| {
111                arr.iter()
112                    .filter_map(|v| v.as_str())
113                    .map(String::from)
114                    .collect::<Vec<_>>()
115            })
116            .unwrap_or_default();
117
118        Ok(normal
119            .iter()
120            .filter_map(|v| {
121                let version = v.as_str()?.to_string();
122                Some(VersionMeta {
123                    yanked: deprecated.contains(&version),
124                    version,
125                    released: None,
126                })
127            })
128            .collect())
129    }
130
131    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
132        // Hackage search endpoint
133        let url = format!(
134            "{}/packages/search?terms={}",
135            Self::API_BASE,
136            urlencoding::encode(query)
137        );
138        let response: serde_json::Value = ureq::get(&url)
139            .set("Accept", "application/json")
140            .call()?
141            .into_json()?;
142
143        let packages = response
144            .as_array()
145            .ok_or_else(|| IndexError::Parse("expected array".into()))?;
146
147        Ok(packages
148            .iter()
149            .take(50)
150            .filter_map(|pkg| {
151                Some(PackageMeta {
152                    name: pkg["name"].as_str()?.to_string(),
153                    version: "unknown".to_string(), // Search doesn't return version
154                    description: pkg["synopsis"].as_str().map(String::from),
155                    homepage: None,
156                    repository: None,
157                    license: None,
158                    binaries: Vec::new(),
159                    keywords: Vec::new(),
160                    maintainers: Vec::new(),
161                    published: None,
162                    downloads: pkg["downloads"].as_u64(),
163                    archive_url: None,
164                    checksum: None,
165                    extra: Default::default(),
166                })
167            })
168            .collect())
169    }
170}