Skip to main content

normalize_package_index/index/
flatpak.rs

1//! Flathub package index fetcher (Flatpak apps).
2//!
3//! Fetches package metadata from Flathub API.
4//!
5//! ## API Strategy
6//! - **fetch**: `flathub.org/api/v2/appstream/{app_id}` - Official Flathub JSON API
7//! - **fetch_versions**: Same API, extracts releases array
8//! - **search**: `flathub.org/api/v2/search?q=` - Flathub search
9//! - **fetch_all**: `flathub.org/api/v2/appstream` (all apps)
10//!
11//! ## Multi-remote Support
12//! ```rust,ignore
13//! use normalize_packages::index::flatpak::{Flatpak, FlatpakRemote};
14//!
15//! // All remotes (default)
16//! let all = Flatpak::all();
17//!
18//! // Flathub only
19//! let flathub = Flatpak::flathub();
20//! ```
21
22use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
23use std::collections::HashMap;
24
25/// Available Flatpak remotes.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum FlatpakRemote {
28    /// Flathub - the main Flatpak repository
29    Flathub,
30    /// Flathub Beta - testing versions
31    FlathubBeta,
32}
33
34impl FlatpakRemote {
35    /// Get the API base URL for this remote.
36    fn api_url(&self) -> &'static str {
37        match self {
38            Self::Flathub => "https://flathub.org/api/v2",
39            Self::FlathubBeta => "https://beta.flathub.org/api/v2",
40        }
41    }
42
43    /// Get the remote name for tagging.
44    pub fn name(&self) -> &'static str {
45        match self {
46            Self::Flathub => "flathub",
47            Self::FlathubBeta => "flathub-beta",
48        }
49    }
50
51    /// All available remotes.
52    pub fn all() -> &'static [FlatpakRemote] {
53        &[Self::Flathub, Self::FlathubBeta]
54    }
55
56    /// Flathub stable only.
57    pub fn flathub() -> &'static [FlatpakRemote] {
58        &[Self::Flathub]
59    }
60}
61
62/// Flathub package index fetcher with configurable remotes.
63pub struct Flatpak {
64    remotes: Vec<FlatpakRemote>,
65}
66
67impl Flatpak {
68    /// Create a fetcher with all remotes.
69    pub fn all() -> Self {
70        Self {
71            remotes: FlatpakRemote::all().to_vec(),
72        }
73    }
74
75    /// Create a fetcher with Flathub stable only.
76    pub fn flathub() -> Self {
77        Self {
78            remotes: FlatpakRemote::flathub().to_vec(),
79        }
80    }
81
82    /// Create a fetcher with custom remote selection.
83    pub fn with_remotes(remotes: &[FlatpakRemote]) -> Self {
84        Self {
85            remotes: remotes.to_vec(),
86        }
87    }
88
89    /// Fetch an app from a specific remote.
90    fn fetch_from_remote(name: &str, remote: FlatpakRemote) -> Result<PackageMeta, IndexError> {
91        let url = format!("{}/appstream/{}", remote.api_url(), name);
92        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
93
94        Ok(app_to_meta(&response, name, remote))
95    }
96
97    /// Fetch versions from a specific remote.
98    fn fetch_versions_from_remote(
99        name: &str,
100        remote: FlatpakRemote,
101    ) -> Result<Vec<VersionMeta>, IndexError> {
102        let url = format!("{}/appstream/{}", remote.api_url(), name);
103        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
104
105        // Flathub typically has only the current version
106        let version = response["releases"]
107            .as_array()
108            .and_then(|r| r.first())
109            .and_then(|r| r["version"].as_str())
110            .or_else(|| response["bundle"]["runtime"].as_str())
111            .unwrap_or("unknown");
112
113        Ok(vec![VersionMeta {
114            version: format!("{} ({})", version, remote.name()),
115            released: response["releases"]
116                .as_array()
117                .and_then(|r| r.first())
118                .and_then(|r| r["timestamp"].as_str())
119                .map(String::from),
120            yanked: false,
121        }])
122    }
123
124    /// Search a specific remote.
125    fn search_remote(query: &str, remote: FlatpakRemote) -> Result<Vec<PackageMeta>, IndexError> {
126        let url = format!(
127            "{}/search?q={}",
128            remote.api_url(),
129            urlencoding::encode(query)
130        );
131        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
132
133        let hits = response["hits"]
134            .as_array()
135            .ok_or_else(|| IndexError::Parse("missing hits".into()))?;
136
137        Ok(hits
138            .iter()
139            .take(50)
140            .filter_map(|hit| {
141                let mut extra = HashMap::new();
142                extra.insert(
143                    "source_repo".to_string(),
144                    serde_json::Value::String(remote.name().to_string()),
145                );
146
147                Some(PackageMeta {
148                    name: hit["id"].as_str()?.to_string(),
149                    version: "unknown".to_string(),
150                    description: hit["summary"].as_str().map(String::from),
151                    homepage: hit["project_url"].as_str().map(String::from),
152                    repository: None,
153                    license: None,
154                    binaries: Vec::new(),
155                    keywords: Vec::new(),
156                    maintainers: Vec::new(),
157                    published: None,
158                    downloads: None,
159                    archive_url: None,
160                    checksum: None,
161                    extra,
162                })
163            })
164            .collect())
165    }
166
167    /// Fetch all apps from a specific remote.
168    fn fetch_all_from_remote(remote: FlatpakRemote) -> Result<Vec<PackageMeta>, IndexError> {
169        let url = format!("{}/appstream", remote.api_url());
170        let app_ids: Vec<String> = ureq::get(&url).call()?.into_json()?;
171
172        let mut extra = HashMap::new();
173        extra.insert(
174            "source_repo".to_string(),
175            serde_json::Value::String(remote.name().to_string()),
176        );
177
178        Ok(app_ids
179            .into_iter()
180            .map(|id| PackageMeta {
181                name: id,
182                version: "unknown".to_string(),
183                description: None,
184                homepage: None,
185                repository: None,
186                license: None,
187                binaries: Vec::new(),
188                keywords: Vec::new(),
189                maintainers: Vec::new(),
190                published: None,
191                downloads: None,
192                archive_url: None,
193                checksum: None,
194                extra: extra.clone(),
195            })
196            .collect())
197    }
198}
199
200impl PackageIndex for Flatpak {
201    fn ecosystem(&self) -> &'static str {
202        "flatpak"
203    }
204
205    fn display_name(&self) -> &'static str {
206        "Flathub (Flatpak)"
207    }
208
209    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
210        // Try each configured remote until we find the app
211        for &remote in &self.remotes {
212            match Self::fetch_from_remote(name, remote) {
213                Ok(pkg) => return Ok(pkg),
214                Err(IndexError::Network(_)) => continue,
215                Err(e) => return Err(e),
216            }
217        }
218
219        Err(IndexError::NotFound(name.to_string()))
220    }
221
222    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
223        let mut all_versions = Vec::new();
224
225        for &remote in &self.remotes {
226            if let Ok(versions) = Self::fetch_versions_from_remote(name, remote) {
227                all_versions.extend(versions);
228            }
229        }
230
231        if all_versions.is_empty() {
232            return Err(IndexError::NotFound(name.to_string()));
233        }
234
235        Ok(all_versions)
236    }
237
238    fn supports_fetch_all(&self) -> bool {
239        true
240    }
241
242    fn fetch_all(&self) -> Result<Vec<PackageMeta>, IndexError> {
243        let mut all_apps = Vec::new();
244
245        for &remote in &self.remotes {
246            if let Ok(apps) = Self::fetch_all_from_remote(remote) {
247                all_apps.extend(apps);
248            }
249        }
250
251        Ok(all_apps)
252    }
253
254    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
255        let mut results = Vec::new();
256
257        for &remote in &self.remotes {
258            if let Ok(packages) = Self::search_remote(query, remote) {
259                results.extend(packages);
260            }
261        }
262
263        Ok(results)
264    }
265}
266
267fn app_to_meta(app: &serde_json::Value, fallback_name: &str, remote: FlatpakRemote) -> PackageMeta {
268    let version = app["releases"]
269        .as_array()
270        .and_then(|r| r.first())
271        .and_then(|r| r["version"].as_str())
272        .unwrap_or("unknown");
273
274    let mut extra = HashMap::new();
275    extra.insert(
276        "source_repo".to_string(),
277        serde_json::Value::String(remote.name().to_string()),
278    );
279
280    let published = app["releases"]
281        .as_array()
282        .and_then(|r| r.first())
283        .and_then(|r| r["timestamp"].as_str())
284        .map(String::from);
285
286    PackageMeta {
287        name: app["id"].as_str().unwrap_or(fallback_name).to_string(),
288        version: version.to_string(),
289        description: app["summary"].as_str().map(String::from),
290        homepage: app["project_url"].as_str().map(String::from),
291        repository: app["vcs_url"].as_str().map(String::from),
292        license: app["project_license"].as_str().map(String::from),
293        binaries: Vec::new(),
294        keywords: app["categories"]
295            .as_array()
296            .map(|c| {
297                c.iter()
298                    .filter_map(|v| v.as_str().map(String::from))
299                    .collect()
300            })
301            .unwrap_or_default(),
302        maintainers: app["developer_name"]
303            .as_str()
304            .map(|d| vec![d.to_string()])
305            .unwrap_or_default(),
306        published,
307        downloads: app["installs_last_month"].as_u64(),
308        archive_url: None,
309        checksum: None,
310        extra,
311    }
312}