1use 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
19pub 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#[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#[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 #[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}