Skip to main content

modde_sources/github/
mod.rs

1//! GitHub Releases download source: resolves release assets and downloads
2//! them, honouring `GITHUB_TOKEN` when present.
3
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::Arc;
7
8use anyhow::Result;
9use reqwest::Client;
10use serde::Deserialize;
11use tracing::info;
12
13use modde_core::manifest::wabbajack::DownloadDirective;
14
15use crate::common::{ensure_parent, simple_download, with_retry};
16use crate::error::{SourceError, SourceResult, status_error};
17use crate::traits::{DownloadHandle, DownloadSource, ProgressCallback, VerifiedFile};
18
19/// GitHub Releases download source.
20pub struct GitHubSource {
21    client: Client,
22    token: Option<String>,
23}
24
25#[derive(Debug, Deserialize)]
26struct Release {
27    tag_name: Option<String>,
28    name: Option<String>,
29    assets: Vec<ReleaseAsset>,
30}
31
32#[derive(Debug, Deserialize)]
33struct ReleaseAsset {
34    name: String,
35    browser_download_url: String,
36    size: u64,
37}
38
39/// Summary of a single GitHub release: its tag, optional name, and assets.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct GitHubReleaseSummary {
42    pub tag: String,
43    pub name: Option<String>,
44    pub assets: Vec<GitHubReleaseAsset>,
45}
46
47/// A downloadable asset attached to a GitHub release.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GitHubReleaseAsset {
50    pub name: String,
51    pub browser_download_url: String,
52    pub size: u64,
53}
54
55impl GitHubSource {
56    /// Create a source over the given HTTP `client`, picking up `GITHUB_TOKEN`
57    /// from the environment for authenticated requests.
58    #[must_use]
59    pub fn new(client: Client) -> Self {
60        let token = std::env::var("GITHUB_TOKEN").ok();
61        Self { client, token }
62    }
63
64    async fn get_json<T: for<'de> Deserialize<'de>>(&self, url: &str) -> SourceResult<T> {
65        let mut req = self.client.get(url).header("User-Agent", "modde");
66        if let Some(token) = &self.token {
67            req = req.header("Authorization", format!("Bearer {token}"));
68        }
69        Ok(status_error(req.send().await?)?.json().await?)
70    }
71
72    pub async fn list_releases(&self, user: &str, repo: &str) -> Result<Vec<GitHubReleaseSummary>> {
73        let url = format!("https://api.github.com/repos/{user}/{repo}/releases");
74        let releases: Vec<Release> = self.get_json(&url).await?;
75        Ok(releases.into_iter().filter_map(release_summary).collect())
76    }
77
78    pub async fn release_by_tag(
79        &self,
80        user: &str,
81        repo: &str,
82        tag: &str,
83    ) -> Result<GitHubReleaseSummary> {
84        let url = format!("https://api.github.com/repos/{user}/{repo}/releases/tags/{tag}");
85        let release: Release = self.get_json(&url).await?;
86        release_summary(release).ok_or_else(|| anyhow::anyhow!("release {tag} has no tag name"))
87    }
88
89    pub async fn download_release_asset(
90        &self,
91        user: &str,
92        repo: &str,
93        tag: &str,
94        asset: &str,
95        dest: &Path,
96    ) -> Result<VerifiedFile> {
97        let release = self.release_by_tag(user, repo, tag).await?;
98        let found = release
99            .assets
100            .iter()
101            .find(|candidate| candidate.name == asset)
102            .ok_or_else(|| anyhow::anyhow!("asset '{asset}' not found in release {tag}"))?;
103        let handle = DownloadHandle {
104            url: found.browser_download_url.clone(),
105            candidate_urls: Vec::new(),
106            headers: HashMap::new(),
107            expected_hash: 0,
108            size_hint: Some(found.size),
109        };
110        ensure_parent(dest).await?;
111        let progress: ProgressCallback = Arc::new(|_, _| {});
112        let resp = status_error(self.client.get(&handle.url).send().await?)?;
113        let total = handle.size_hint.unwrap_or(0);
114        let mut file = tokio::fs::File::create(dest).await?;
115        let mut stream = resp.bytes_stream();
116        let mut downloaded = 0;
117        use futures::StreamExt;
118        use tokio::io::AsyncWriteExt;
119        while let Some(chunk) = stream.next().await {
120            let chunk = chunk?;
121            file.write_all(&chunk).await?;
122            downloaded += chunk.len() as u64;
123            progress(downloaded, total);
124        }
125        file.flush().await?;
126        let hash = modde_core::hash::hash_file_xxhash(dest).await?;
127        Ok(VerifiedFile {
128            path: dest.to_path_buf(),
129            hash,
130        })
131    }
132}
133
134fn release_summary(release: Release) -> Option<GitHubReleaseSummary> {
135    Some(GitHubReleaseSummary {
136        tag: release.tag_name?,
137        name: release.name,
138        assets: release
139            .assets
140            .into_iter()
141            .map(|asset| GitHubReleaseAsset {
142                name: asset.name,
143                browser_download_url: asset.browser_download_url,
144                size: asset.size,
145            })
146            .collect(),
147    })
148}
149
150impl DownloadSource for GitHubSource {
151    fn can_handle(&self, directive: &DownloadDirective) -> bool {
152        matches!(directive, DownloadDirective::GitHub { .. })
153    }
154
155    async fn resolve(&self, directive: &DownloadDirective) -> SourceResult<DownloadHandle> {
156        let DownloadDirective::GitHub {
157            user,
158            repo,
159            tag,
160            asset,
161            hash,
162        } = directive
163        else {
164            return Err(SourceError::other(anyhow::anyhow!(
165                "not a GitHub directive"
166            )));
167        };
168
169        let url = format!("https://api.github.com/repos/{user}/{repo}/releases/tags/{tag}");
170
171        let mut req = self.client.get(&url).header("User-Agent", "modde");
172        if let Some(token) = &self.token {
173            req = req.header("Authorization", format!("Bearer {token}"));
174        }
175
176        let release: Release = status_error(req.send().await?)?.json().await?;
177
178        let found = release
179            .assets
180            .iter()
181            .find(|a| a.name == *asset)
182            .ok_or_else(|| {
183                SourceError::other(anyhow::anyhow!(
184                    "asset '{asset}' not found in release {tag}"
185                ))
186            })?;
187
188        info!(repo = %format!("{user}/{repo}"), tag, asset, "resolved GitHub release asset");
189
190        Ok(DownloadHandle {
191            url: found.browser_download_url.clone(),
192            candidate_urls: Vec::new(),
193            headers: HashMap::new(),
194            expected_hash: *hash,
195            size_hint: Some(found.size),
196        })
197    }
198
199    async fn download_with_progress(
200        &self,
201        handle: DownloadHandle,
202        dest: &Path,
203        progress: ProgressCallback,
204    ) -> SourceResult<VerifiedFile> {
205        let client = self.client.clone();
206        let handle_ref = &handle;
207        let dest_ref = dest;
208        let progress_ref = &progress;
209
210        with_retry("GitHub download", || async {
211            simple_download(&client, handle_ref, dest_ref, progress_ref).await
212        })
213        .await
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn github_release_summary_preserves_tag_and_asset_names() {
223        let release: Release = serde_json::from_str(
224            r#"{
225                "tag_name": "v0.7.7",
226                "name": "OptiScaler v0.7.7",
227                "assets": [
228                    {
229                        "name": "OptiScaler_v0.7.7.zip",
230                        "browser_download_url": "https://example.test/OptiScaler.zip",
231                        "size": 1234
232                    }
233                ]
234            }"#,
235        )
236        .expect("release json parses");
237        let summary = release_summary(release).expect("release has tag");
238        assert_eq!(summary.tag, "v0.7.7");
239        assert_eq!(summary.assets[0].name, "OptiScaler_v0.7.7.zip");
240        assert_eq!(summary.assets[0].size, 1234);
241    }
242}