Skip to main content

normalize_package_index/index/
brew.rs

1//! Homebrew package index fetcher (macOS/Linux).
2//!
3//! Fetches package metadata from Homebrew's formula JSON API.
4//!
5//! ## API Strategy
6//! - **fetch**: `formulae.brew.sh/api/formula/{name}.json` - Official JSON API
7//! - **fetch_versions**: `formulae.brew.sh/api/formula/{name}.json` versions array
8//! - **search**: Filters `formulae.brew.sh/api/formula.json` (all formulas)
9//! - **fetch_all**: `formulae.brew.sh/api/formula.json` (cached 1 hour)
10
11use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
12use crate::cache;
13use std::collections::HashMap;
14use std::time::Duration;
15
16/// Default cache TTL for formula list (1 hour).
17const INDEX_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
18
19/// Homebrew package index fetcher.
20pub struct Brew;
21
22impl Brew {
23    /// Homebrew formula API.
24    const BREW_API: &'static str = "https://formulae.brew.sh/api";
25}
26
27impl PackageIndex for Brew {
28    fn ecosystem(&self) -> &'static str {
29        "brew"
30    }
31
32    fn display_name(&self) -> &'static str {
33        "Homebrew (macOS/Linux)"
34    }
35
36    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
37        let url = format!("{}/formula/{}.json", Self::BREW_API, name);
38        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
39
40        // Extract 365-day install count from analytics
41        let downloads = response["analytics"]["install"]["365d"]
42            .as_object()
43            .and_then(|obj| obj.values().filter_map(|v| v.as_u64()).next());
44
45        // Collect aliases as keywords
46        let mut keywords: Vec<String> = response["aliases"]
47            .as_array()
48            .map(|a| {
49                a.iter()
50                    .filter_map(|v| v.as_str().map(String::from))
51                    .collect()
52            })
53            .unwrap_or_default();
54
55        // Add oldnames to keywords too
56        if let Some(oldnames) = response["oldnames"].as_array() {
57            keywords.extend(oldnames.iter().filter_map(|v| v.as_str().map(String::from)));
58        }
59
60        // Build extra metadata
61        let mut extra = HashMap::new();
62
63        // Dependencies
64        if let Some(deps) = response["dependencies"].as_array()
65            && !deps.is_empty()
66        {
67            extra.insert(
68                "dependencies".to_string(),
69                serde_json::Value::Array(deps.clone()),
70            );
71        }
72        if let Some(build_deps) = response["build_dependencies"].as_array()
73            && !build_deps.is_empty()
74        {
75            extra.insert(
76                "build_dependencies".to_string(),
77                serde_json::Value::Array(build_deps.clone()),
78            );
79        }
80
81        // Tap info
82        if let Some(tap) = response["tap"].as_str() {
83            extra.insert("tap".to_string(), serde_json::json!(tap));
84        }
85
86        Ok(PackageMeta {
87            name: response["name"].as_str().unwrap_or(name).to_string(),
88            version: response["versions"]["stable"]
89                .as_str()
90                .unwrap_or("unknown")
91                .to_string(),
92            description: response["desc"].as_str().map(String::from),
93            homepage: response["homepage"].as_str().map(String::from),
94            repository: extract_repository(&response),
95            license: response["license"].as_str().map(String::from),
96            binaries: response["bin"]
97                .as_array()
98                .map(|bins| {
99                    bins.iter()
100                        .filter_map(|b| b.as_str().map(String::from))
101                        .collect()
102                })
103                .unwrap_or_default(),
104            keywords,
105            maintainers: Vec::new(), // Not exposed in API
106            published: response["generated_date"].as_str().map(String::from),
107            downloads,
108            archive_url: response["urls"]["stable"]["url"].as_str().map(String::from),
109            checksum: response["urls"]["stable"]["checksum"]
110                .as_str()
111                .map(|h| format!("sha256:{}", h)),
112            extra,
113        })
114    }
115
116    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
117        let url = format!("{}/formula/{}.json", Self::BREW_API, name);
118        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
119
120        let mut versions = Vec::new();
121
122        // Current stable version
123        if let Some(stable) = response["versions"]["stable"].as_str() {
124            versions.push(VersionMeta {
125                version: stable.to_string(),
126                released: None,
127                yanked: false,
128            });
129        }
130
131        // HEAD version if available
132        if response["versions"]["head"].as_str().is_some() {
133            versions.push(VersionMeta {
134                version: "HEAD".to_string(),
135                released: None,
136                yanked: false,
137            });
138        }
139
140        // Versioned formulae (e.g., python@3.11)
141        if let Some(versioned) = response["versioned_formulae"].as_array() {
142            for v in versioned {
143                if let Some(name) = v.as_str() {
144                    // Extract version from name like "python@3.11"
145                    if let Some(ver) = name.split('@').nth(1) {
146                        versions.push(VersionMeta {
147                            version: ver.to_string(),
148                            released: None,
149                            yanked: false,
150                        });
151                    }
152                }
153            }
154        }
155
156        if versions.is_empty() {
157            return Err(IndexError::NotFound(name.to_string()));
158        }
159
160        Ok(versions)
161    }
162
163    fn supports_fetch_all(&self) -> bool {
164        true
165    }
166
167    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
168        let url = format!("{}/formula.json", Self::BREW_API);
169
170        // Try cache first
171        let (data, _was_cached) =
172            cache::fetch_with_cache(self.ecosystem(), "formula-all", &url, INDEX_CACHE_TTL)
173                .map_err(IndexError::Network)?;
174
175        let response: Vec<serde_json::Value> = serde_json::from_slice(&data)?;
176
177        Ok(response
178            .into_iter()
179            .filter_map(|formula| {
180                let downloads = formula["analytics"]["install"]["365d"]
181                    .as_object()
182                    .and_then(|obj| obj.values().filter_map(|v| v.as_u64()).next());
183
184                let mut keywords: Vec<String> = formula["aliases"]
185                    .as_array()
186                    .map(|a| {
187                        a.iter()
188                            .filter_map(|v| v.as_str().map(String::from))
189                            .collect()
190                    })
191                    .unwrap_or_default();
192                if let Some(oldnames) = formula["oldnames"].as_array() {
193                    keywords.extend(oldnames.iter().filter_map(|v| v.as_str().map(String::from)));
194                }
195
196                Some(PackageMeta {
197                    name: formula["name"].as_str()?.to_string(),
198                    version: formula["versions"]["stable"]
199                        .as_str()
200                        .unwrap_or("unknown")
201                        .to_string(),
202                    description: formula["desc"].as_str().map(String::from),
203                    homepage: formula["homepage"].as_str().map(String::from),
204                    repository: extract_repository(&formula),
205                    license: formula["license"].as_str().map(String::from),
206                    binaries: Vec::new(),
207                    keywords,
208                    maintainers: Vec::new(),
209                    published: formula["generated_date"].as_str().map(String::from),
210                    downloads,
211                    archive_url: formula["urls"]["stable"]["url"].as_str().map(String::from),
212                    checksum: formula["urls"]["stable"]["checksum"]
213                        .as_str()
214                        .map(|h| format!("sha256:{}", h)),
215                    extra: Default::default(),
216                })
217            })
218            .collect())
219    }
220
221    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
222        // Homebrew doesn't have a search API, fetch all and filter
223        let all = self.fetch_all()?;
224        let query_lower = query.to_lowercase();
225
226        Ok(all
227            .into_iter()
228            .filter(|p| {
229                p.name.to_lowercase().contains(&query_lower)
230                    || p.description
231                        .as_ref()
232                        .is_some_and(|d| d.to_lowercase().contains(&query_lower))
233            })
234            .collect())
235    }
236}
237
238fn extract_repository(formula: &serde_json::Value) -> Option<String> {
239    // Try to get repository from urls.stable.url (often GitHub releases)
240    let url = formula["urls"]["stable"]["url"].as_str()?;
241
242    if url.contains("github.com") {
243        // Extract owner/repo from GitHub URL
244        // e.g., https://github.com/BurntSushi/ripgrep/archive/14.1.0.tar.gz
245        let parts: Vec<&str> = url.split('/').collect();
246        if let Some(github_idx) = parts.iter().position(|&p| p == "github.com")
247            && parts.len() > github_idx + 2
248        {
249            return Some(format!(
250                "https://github.com/{}/{}",
251                parts[github_idx + 1],
252                parts[github_idx + 2]
253            ));
254        }
255    }
256
257    // Fallback to homepage if it's a GitHub URL
258    let homepage = formula["homepage"].as_str()?;
259    if homepage.contains("github.com") {
260        return Some(homepage.to_string());
261    }
262
263    None
264}