Skip to main content

normalize_package_index/index/
choco.rs

1//! Chocolatey package index fetcher (Windows).
2//!
3//! Fetches package metadata from the Chocolatey community repository.
4//! Uses the NuGet v2 OData API which returns XML (Atom feed format).
5//!
6//! ## API Strategy
7//! - **fetch**: `community.chocolatey.org/api/v2/Packages(Id='{name}')` - NuGet OData XML
8//! - **fetch_versions**: `community.chocolatey.org/api/v2/FindPackagesById()?id='{name}'`
9//! - **search**: `community.chocolatey.org/api/v2/Search()?searchTerm='{query}'`
10//! - **fetch_all**: Not supported (API requires search terms)
11//!
12//! ## Multi-source Support
13//! ```rust,ignore
14//! use normalize_packages::index::choco::{Choco, ChocoSource};
15//!
16//! // All sources (default)
17//! let all = Choco::all();
18//!
19//! // Community only
20//! let community = Choco::community();
21//! ```
22
23use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
24use quick_xml::de::from_str;
25use serde::Deserialize;
26use std::collections::HashMap;
27use std::io::Read;
28
29/// Available Chocolatey sources.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ChocoSource {
32    /// Chocolatey community repository
33    Community,
34}
35
36impl ChocoSource {
37    /// Get the API base URL for this source.
38    fn api_url(&self) -> &'static str {
39        match self {
40            Self::Community => "https://community.chocolatey.org/api/v2",
41        }
42    }
43
44    /// Get the source name for tagging.
45    pub fn name(&self) -> &'static str {
46        match self {
47            Self::Community => "community",
48        }
49    }
50
51    /// All available sources.
52    pub fn all() -> &'static [ChocoSource] {
53        &[Self::Community]
54    }
55}
56
57/// Chocolatey package index fetcher with configurable sources.
58pub struct Choco {
59    sources: Vec<ChocoSource>,
60}
61
62impl Choco {
63    /// Create a fetcher with all sources.
64    pub fn all() -> Self {
65        Self {
66            sources: ChocoSource::all().to_vec(),
67        }
68    }
69
70    /// Create a fetcher with community source only.
71    pub fn community() -> Self {
72        Self {
73            sources: vec![ChocoSource::Community],
74        }
75    }
76
77    /// Create a fetcher with custom source selection.
78    pub fn with_sources(sources: &[ChocoSource]) -> Self {
79        Self {
80            sources: sources.to_vec(),
81        }
82    }
83
84    /// Fetch a package from a specific source.
85    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    /// Fetch versions from a specific source.
106    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    /// Search a specific source.
128    fn search_source(query: &str, source: ChocoSource) -> Result<Vec<PackageMeta>, IndexError> {
129        // Limit to 10 results (XML responses are verbose)
130        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        // Read full response body (into_string has 10MB limit which should be plenty)
138        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// OData Atom feed structures for deserialization
151// Note: quick_xml serde sees namespace prefixes as literal element names
152#[derive(Debug, Deserialize)]
153struct Feed {
154    #[serde(rename = "entry", default)]
155    entries: Vec<Entry>,
156}
157
158#[derive(Debug, Deserialize)]
159struct Entry {
160    // Try both prefixed and unprefixed forms
161    #[serde(rename = "m:properties", alias = "properties", default)]
162    properties: Option<Properties>,
163}
164
165#[derive(Debug, Deserialize)]
166struct Properties {
167    // quick_xml sees "d:Id" as the element name when namespace prefixes are used
168    #[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(),    // NuGet OData doesn't expose tags in search
213            maintainers: Vec::new(), // Not exposed in OData API
214            published: self.published.clone(),
215            downloads: None, // Would need separate API call
216            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
231/// Sanitize potentially malformed OData XML from Chocolatey API.
232/// The Search endpoint sometimes returns truncated responses with unclosed `<link rel="next">` tags.
233fn sanitize_odata_xml(xml: &str) -> String {
234    // If we have an unclosed <link rel="next">, remove it and close the feed
235    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        // If there's no closing feed tag, add it
241        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    // Try to parse as a feed with multiple entries
253    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            // Try to parse as a single entry
263            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 the feed error since that's more likely what we expected
271                    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        // Try each configured source until we find the package
296        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}