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                    if !author.is_empty() {
71                        m.push(author.to_string());
72                    }
73                }
74                if let Some(maintainer) = response["maintainer"].as_str() {
75                    if !maintainer.is_empty() && !m.contains(&maintainer.to_string()) {
76                        m.push(maintainer.to_string());
77                    }
78                }
79                m
80            },
81            published: None, // Hackage doesn't expose upload time in this endpoint
82            downloads: response["downloads"].as_u64(),
83            archive_url: Some(format!(
84                "{}/package/{}-{}/{}-{}.tar.gz",
85                Self::API_BASE,
86                name,
87                latest_version,
88                name,
89                latest_version
90            )),
91            checksum: None, // Would need to download .cabal file
92            extra: Default::default(),
93        })
94    }
95
96    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
97        let url = format!("{}/package/{}/preferred", Self::API_BASE, name);
98        let response: serde_json::Value = ureq::get(&url)
99            .set("Accept", "application/json")
100            .call()?
101            .into_json()?;
102
103        let normal = response["normal-version"]
104            .as_array()
105            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
106
107        let deprecated = response["deprecated-version"]
108            .as_array()
109            .map(|arr| {
110                arr.iter()
111                    .filter_map(|v| v.as_str())
112                    .map(String::from)
113                    .collect::<Vec<_>>()
114            })
115            .unwrap_or_default();
116
117        Ok(normal
118            .iter()
119            .filter_map(|v| {
120                let version = v.as_str()?.to_string();
121                Some(VersionMeta {
122                    yanked: deprecated.contains(&version),
123                    version,
124                    released: None,
125                })
126            })
127            .collect())
128    }
129
130    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
131        // Hackage search endpoint
132        let url = format!(
133            "{}/packages/search?terms={}",
134            Self::API_BASE,
135            urlencoding::encode(query)
136        );
137        let response: serde_json::Value = ureq::get(&url)
138            .set("Accept", "application/json")
139            .call()?
140            .into_json()?;
141
142        let packages = response
143            .as_array()
144            .ok_or_else(|| IndexError::Parse("expected array".into()))?;
145
146        Ok(packages
147            .iter()
148            .take(50)
149            .filter_map(|pkg| {
150                Some(PackageMeta {
151                    name: pkg["name"].as_str()?.to_string(),
152                    version: "unknown".to_string(), // Search doesn't return version
153                    description: pkg["synopsis"].as_str().map(String::from),
154                    homepage: None,
155                    repository: None,
156                    license: None,
157                    binaries: Vec::new(),
158                    keywords: Vec::new(),
159                    maintainers: Vec::new(),
160                    published: None,
161                    downloads: pkg["downloads"].as_u64(),
162                    archive_url: None,
163                    checksum: None,
164                    extra: Default::default(),
165                })
166            })
167            .collect())
168    }
169}