normalize_package_index/index/
brew.rs1use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
12use crate::cache;
13use std::collections::HashMap;
14use std::time::Duration;
15
16const INDEX_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
18
19pub struct Brew;
21
22impl Brew {
23 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 let downloads = response["analytics"]["install"]["365d"]
42 .as_object()
43 .and_then(|obj| obj.values().filter_map(|v| v.as_u64()).next());
44
45 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 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 let mut extra = HashMap::new();
62
63 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 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(), 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 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 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 if let Some(versioned) = response["versioned_formulae"].as_array() {
142 for v in versioned {
143 if let Some(name) = v.as_str() {
144 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 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 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 let url = formula["urls"]["stable"]["url"].as_str()?;
241
242 if url.contains("github.com") {
243 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 let homepage = formula["homepage"].as_str()?;
259 if homepage.contains("github.com") {
260 return Some(homepage.to_string());
261 }
262
263 None
264}