normalize_package_index/index/
choco.rs1use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
24use quick_xml::de::from_str;
25use serde::Deserialize;
26use std::collections::HashMap;
27use std::io::Read;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ChocoSource {
32 Community,
34}
35
36impl ChocoSource {
37 fn api_url(&self) -> &'static str {
39 match self {
40 Self::Community => "https://community.chocolatey.org/api/v2",
41 }
42 }
43
44 pub fn name(&self) -> &'static str {
46 match self {
47 Self::Community => "community",
48 }
49 }
50
51 pub fn all() -> &'static [ChocoSource] {
53 &[Self::Community]
54 }
55}
56
57pub struct Choco {
59 sources: Vec<ChocoSource>,
60}
61
62impl Choco {
63 pub fn all() -> Self {
65 Self {
66 sources: ChocoSource::all().to_vec(),
67 }
68 }
69
70 pub fn community() -> Self {
72 Self {
73 sources: vec![ChocoSource::Community],
74 }
75 }
76
77 pub fn with_sources(sources: &[ChocoSource]) -> Self {
79 Self {
80 sources: sources.to_vec(),
81 }
82 }
83
84 fn fetch_from_source(name: &str, source: ChocoSource) -> Result<PackageMeta, IndexError> {
86 let url = format!(
87 "{}/Packages()?$filter=Id%20eq%20'{}'%20and%20IsLatestVersion&$top=1",
88 source.api_url(),
89 urlencoding::encode(name)
90 );
91
92 let response = ureq::get(&url).call()?;
93 let xml = response.into_string()?;
94
95 let packages = parse_odata_response(&xml)?;
96 let props = packages
97 .first()
98 .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
99
100 props
101 .to_package_meta(name, source)
102 .ok_or_else(|| IndexError::NotFound(name.to_string()))
103 }
104
105 fn fetch_versions_from_source(
107 name: &str,
108 source: ChocoSource,
109 ) -> Result<Vec<VersionMeta>, IndexError> {
110 let url = format!(
111 "{}/Packages()?$filter=Id%20eq%20'{}'&$orderby=Version%20desc&$top=20",
112 source.api_url(),
113 urlencoding::encode(name)
114 );
115
116 let response = ureq::get(&url).call()?;
117 let xml = response.into_string()?;
118
119 let packages = parse_odata_response(&xml)?;
120
121 Ok(packages
122 .iter()
123 .filter_map(|p| p.to_version_meta())
124 .collect())
125 }
126
127 fn search_source(query: &str, source: ChocoSource) -> Result<Vec<PackageMeta>, IndexError> {
129 let url = format!(
131 "{}/Search()?searchTerm='{}'&includePrerelease=false&$top=10",
132 source.api_url(),
133 urlencoding::encode(query)
134 );
135
136 let response = ureq::get(&url).call()?;
137 let mut xml = String::new();
139 response.into_reader().read_to_string(&mut xml)?;
140
141 let packages = parse_odata_response(&xml)?;
142
143 Ok(packages
144 .iter()
145 .filter_map(|p| p.to_package_meta("", source))
146 .collect())
147 }
148}
149
150#[derive(Debug, Deserialize)]
153struct Feed {
154 #[serde(rename = "entry", default)]
155 entries: Vec<Entry>,
156}
157
158#[derive(Debug, Deserialize)]
159struct Entry {
160 #[serde(rename = "m:properties", alias = "properties", default)]
162 properties: Option<Properties>,
163}
164
165#[derive(Debug, Deserialize)]
166struct Properties {
167 #[serde(rename = "d:Id", alias = "Id", default)]
169 id: Option<String>,
170 #[serde(rename = "d:Version", alias = "Version", default)]
171 version: Option<String>,
172 #[serde(rename = "d:Description", alias = "Description", default)]
173 description: Option<String>,
174 #[serde(rename = "d:Summary", alias = "Summary", default)]
175 summary: Option<String>,
176 #[serde(rename = "d:ProjectUrl", alias = "ProjectUrl", default)]
177 project_url: Option<String>,
178 #[serde(rename = "d:ProjectSourceUrl", alias = "ProjectSourceUrl", default)]
179 project_source_url: Option<String>,
180 #[serde(rename = "d:PackageSourceUrl", alias = "PackageSourceUrl", default)]
181 package_source_url: Option<String>,
182 #[serde(rename = "d:LicenseUrl", alias = "LicenseUrl", default)]
183 license_url: Option<String>,
184 #[serde(rename = "d:Published", alias = "Published", default)]
185 published: Option<String>,
186 #[serde(rename = "d:IsPrerelease", alias = "IsPrerelease", default)]
187 is_prerelease: Option<String>,
188}
189
190impl Properties {
191 fn to_package_meta(&self, name: &str, source: ChocoSource) -> Option<PackageMeta> {
192 let mut extra = HashMap::new();
193 extra.insert(
194 "source_repo".to_string(),
195 serde_json::Value::String(source.name().to_string()),
196 );
197
198 Some(PackageMeta {
199 name: self.id.clone().unwrap_or_else(|| name.to_string()),
200 version: self
201 .version
202 .clone()
203 .unwrap_or_else(|| "unknown".to_string()),
204 description: self.description.clone().or_else(|| self.summary.clone()),
205 homepage: self.project_url.clone(),
206 repository: self
207 .project_source_url
208 .clone()
209 .or_else(|| self.package_source_url.clone()),
210 license: self.license_url.clone(),
211 binaries: Vec::new(),
212 keywords: Vec::new(), maintainers: Vec::new(), published: self.published.clone(),
215 downloads: None, archive_url: None,
217 checksum: None,
218 extra,
219 })
220 }
221
222 fn to_version_meta(&self) -> Option<VersionMeta> {
223 Some(VersionMeta {
224 version: self.version.clone()?,
225 released: self.published.clone(),
226 yanked: self.is_prerelease.as_deref() == Some("true"),
227 })
228 }
229}
230
231fn sanitize_odata_xml(xml: &str) -> String {
234 if let Some(pos) = xml.find("<link rel=\"next\">") {
236 let mut sanitized = xml[..pos].to_string();
237 sanitized.push_str("</feed>");
238 sanitized
239 } else if !xml.contains("</feed>") {
240 let mut sanitized = xml.to_string();
242 sanitized.push_str("</feed>");
243 sanitized
244 } else {
245 xml.to_string()
246 }
247}
248
249fn parse_odata_response(xml: &str) -> Result<Vec<Properties>, IndexError> {
250 let xml = sanitize_odata_xml(xml);
251
252 match from_str::<Feed>(&xml) {
254 Ok(feed) => {
255 return Ok(feed
256 .entries
257 .into_iter()
258 .filter_map(|e| e.properties)
259 .collect());
260 }
261 Err(feed_err) => {
262 match from_str::<Entry>(&xml) {
264 Ok(entry) => {
265 if let Some(props) = entry.properties {
266 return Ok(vec![props]);
267 }
268 }
269 Err(_) => {
270 return Err(IndexError::Parse(format!(
272 "failed to parse OData XML: {}",
273 feed_err
274 )));
275 }
276 }
277 }
278 }
279
280 Err(IndexError::Parse(
281 "OData XML parsed but no properties found".into(),
282 ))
283}
284
285impl PackageIndex for Choco {
286 fn ecosystem(&self) -> &'static str {
287 "choco"
288 }
289
290 fn display_name(&self) -> &'static str {
291 "Chocolatey (Windows)"
292 }
293
294 fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
295 for &source in &self.sources {
297 match Self::fetch_from_source(name, source) {
298 Ok(pkg) => return Ok(pkg),
299 Err(IndexError::NotFound(_)) => continue,
300 Err(e) => return Err(e),
301 }
302 }
303
304 Err(IndexError::NotFound(name.to_string()))
305 }
306
307 fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
308 let mut all_versions = Vec::new();
309
310 for &source in &self.sources {
311 if let Ok(versions) = Self::fetch_versions_from_source(name, source) {
312 all_versions.extend(versions);
313 }
314 }
315
316 if all_versions.is_empty() {
317 return Err(IndexError::NotFound(name.to_string()));
318 }
319
320 Ok(all_versions)
321 }
322
323 fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
324 let mut results = Vec::new();
325
326 for &source in &self.sources {
327 if let Ok(packages) = Self::search_source(query, source) {
328 results.extend(packages);
329 }
330 }
331
332 Ok(results)
333 }
334}