vct_core/update/
github.rs1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11const GITHUB_API_RELEASES_URL: &str =
13 "https://api.github.com/repos/Mai0313/VibeCodingTracker/releases/latest";
14const USER_AGENT: &str = concat!("vibe_coding_tracker/", env!("CARGO_PKG_VERSION"));
18const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
19const RELEASE_TIMEOUT: Duration = Duration::from_secs(5);
20const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
21
22#[derive(Debug, Deserialize, Serialize)]
24pub struct GitHubRelease {
25 pub tag_name: String,
27 pub name: String,
29 pub body: Option<String>,
31 pub assets: Vec<GitHubAsset>,
33}
34
35#[derive(Debug, Deserialize, Serialize)]
37pub struct GitHubAsset {
38 pub name: String,
40 pub browser_download_url: String,
42 pub size: u64,
44}
45
46pub fn fetch_latest_release() -> Result<GitHubRelease> {
54 fetch_latest_release_from(GITHUB_API_RELEASES_URL)
55}
56
57pub fn fetch_latest_release_from(url: &str) -> Result<GitHubRelease> {
69 fetch_latest_release_from_with_timeout(url, RELEASE_TIMEOUT)
70}
71
72fn fetch_latest_release_from_with_timeout(url: &str, timeout: Duration) -> Result<GitHubRelease> {
73 let client = reqwest::blocking::Client::builder()
74 .user_agent(USER_AGENT)
75 .connect_timeout(CONNECT_TIMEOUT.min(timeout))
76 .timeout(timeout)
77 .build()
78 .context("Failed to create HTTP client")?;
79
80 let response = client
81 .get(url)
82 .send()
83 .context("Failed to fetch release information from GitHub")?;
84
85 if !response.status().is_success() {
86 anyhow::bail!("GitHub API returned error status: {}", response.status());
87 }
88
89 let release: GitHubRelease = response
90 .json()
91 .context("Failed to parse GitHub release JSON")?;
92
93 Ok(release)
94}
95
96pub fn download_file(url: &str, dest: &std::path::Path) -> Result<u64> {
107 download_file_with_timeout(url, dest, DOWNLOAD_TIMEOUT)
108}
109
110fn download_file_with_timeout(url: &str, dest: &std::path::Path, timeout: Duration) -> Result<u64> {
111 let client = reqwest::blocking::Client::builder()
112 .user_agent(USER_AGENT)
113 .connect_timeout(CONNECT_TIMEOUT.min(timeout))
114 .timeout(timeout)
115 .build()
116 .context("Failed to create HTTP client")?;
117
118 let mut response = client.get(url).send().context("Failed to download file")?;
119
120 if !response.status().is_success() {
121 anyhow::bail!("Download failed with status: {}", response.status());
122 }
123
124 let mut file = std::fs::File::create(dest)
125 .context(format!("Failed to create file: {}", dest.display()))?;
126
127 let bytes = response
128 .copy_to(&mut file)
129 .context("Failed to write downloaded content to file")?;
130
131 Ok(bytes)
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use httpmock::prelude::*;
138 use serde_json::json;
139
140 #[test]
141 fn fetch_latest_release_parses_mock_response() {
142 let server = MockServer::start();
143 let endpoint = server.mock(|when, then| {
144 when.method(GET).path("/releases/latest");
145 then.status(200).json_body(json!({
146 "tag_name": "v1.2.3",
147 "name": "Release 1.2.3",
148 "body": "notes",
149 "assets": [
150 {
151 "name": "vct-linux-x64.tar.gz",
152 "browser_download_url": "https://example.test/vct-linux-x64.tar.gz",
153 "size": 42
154 }
155 ]
156 }));
157 });
158
159 let release = fetch_latest_release_from(&server.url("/releases/latest"))
160 .expect("should parse the release JSON");
161 endpoint.assert();
162 assert_eq!(release.tag_name, "v1.2.3");
163 assert_eq!(release.assets.len(), 1);
164 assert_eq!(release.assets[0].name, "vct-linux-x64.tar.gz");
165 assert_eq!(release.assets[0].size, 42);
166 }
167
168 #[test]
169 fn fetch_latest_release_errors_on_non_success() {
170 let server = MockServer::start();
171 server.mock(|when, then| {
172 when.method(GET).path("/releases/latest");
173 then.status(404);
174 });
175 assert!(fetch_latest_release_from(&server.url("/releases/latest")).is_err());
176 }
177
178 #[test]
179 fn download_file_streams_body_to_disk() {
180 let server = MockServer::start();
181 server.mock(|when, then| {
182 when.method(GET).path("/asset.bin");
183 then.status(200).body("binary-contents");
184 });
185 let dir = tempfile::tempdir().unwrap();
186 let dest = dir.path().join("asset.bin");
187
188 let bytes =
189 download_file(&server.url("/asset.bin"), &dest).expect("download should succeed");
190 assert_eq!(bytes, 15);
191 assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-contents");
192 }
193
194 #[test]
195 fn download_file_errors_on_non_success() {
196 let server = MockServer::start();
197 server.mock(|when, then| {
198 when.method(GET).path("/missing.bin");
199 then.status(500);
200 });
201 let dir = tempfile::tempdir().unwrap();
202 let dest = dir.path().join("missing.bin");
203 assert!(download_file(&server.url("/missing.bin"), &dest).is_err());
204 }
205
206 #[test]
207 fn release_fetch_has_a_bounded_timeout() {
208 let server = MockServer::start();
209 server.mock(|when, then| {
210 when.method(GET).path("/slow");
211 then.status(200)
212 .delay(Duration::from_millis(200))
213 .json_body(json!({
214 "tag_name": "v1.2.3",
215 "name": "Release 1.2.3",
216 "body": null,
217 "assets": []
218 }));
219 });
220
221 let error =
222 fetch_latest_release_from_with_timeout(&server.url("/slow"), Duration::from_millis(20))
223 .expect_err("slow response must time out");
224 assert!(error.to_string().contains("Failed to fetch release"));
225 }
226
227 #[test]
228 fn asset_download_has_a_bounded_timeout() {
229 let server = MockServer::start();
230 server.mock(|when, then| {
231 when.method(GET).path("/slow-asset");
232 then.status(200)
233 .delay(Duration::from_millis(200))
234 .body("binary-contents");
235 });
236 let dir = tempfile::tempdir().unwrap();
237 let dest = dir.path().join("asset.bin");
238
239 let error = download_file_with_timeout(
240 &server.url("/slow-asset"),
241 &dest,
242 Duration::from_millis(20),
243 )
244 .expect_err("slow download must time out");
245 assert!(error.to_string().contains("Failed to download file"));
246 }
247}