Skip to main content

vct_core/update/
github.rs

1//! Minimal GitHub Releases client used by the self-updater.
2//!
3//! Wraps the "latest release" REST endpoint and a streaming file download
4//! using a blocking `reqwest` client. Only the fields the updater needs are
5//! deserialized from the API response.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11/// GitHub REST endpoint for the repository's latest release.
12const GITHUB_API_RELEASES_URL: &str =
13    "https://api.github.com/repos/Mai0313/VibeCodingTracker/releases/latest";
14/// `User-Agent` header value (`<crate>/<version>`), required by the GitHub API.
15// Pinned to the product name (not the crate name, which is `vct-core`) so the
16// GitHub API sees a stable identifier across the workspace rename.
17const 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/// A GitHub release, deserialized from the Releases API.
23#[derive(Debug, Deserialize, Serialize)]
24pub struct GitHubRelease {
25    /// Git tag the release points at (e.g. `"v0.1.6"`).
26    pub tag_name: String,
27    /// Human-readable release title.
28    pub name: String,
29    /// Release notes body, absent when the release has none.
30    pub body: Option<String>,
31    /// Downloadable assets attached to the release.
32    pub assets: Vec<GitHubAsset>,
33}
34
35/// A single downloadable file attached to a [`GitHubRelease`].
36#[derive(Debug, Deserialize, Serialize)]
37pub struct GitHubAsset {
38    /// Asset file name, matched against the platform pattern.
39    pub name: String,
40    /// Direct download URL for the asset.
41    pub browser_download_url: String,
42    /// Asset size in bytes.
43    pub size: u64,
44}
45
46/// Fetches the repository's latest release from the GitHub API.
47///
48/// # Errors
49///
50/// Returns an error if the HTTP client cannot be built, if the request fails,
51/// if GitHub responds with a non-success status, or if the response body is
52/// not the expected release JSON.
53pub fn fetch_latest_release() -> Result<GitHubRelease> {
54    fetch_latest_release_from(GITHUB_API_RELEASES_URL)
55}
56
57/// Fetches the latest release from an explicit endpoint URL.
58///
59/// The injectable counterpart of [`fetch_latest_release`]: production passes the
60/// real GitHub endpoint, tests point `url` at a local mock server so no real API
61/// is reached.
62///
63/// # Errors
64///
65/// Returns an error if the HTTP client cannot be built, if the request fails,
66/// if the server responds with a non-success status, or if the response body is
67/// not the expected release JSON.
68pub 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
96/// Downloads the file at `url` and writes it to `dest`.
97///
98/// Streams the response body straight to the destination file rather than
99/// buffering it in memory, and returns the number of bytes written.
100///
101/// # Errors
102///
103/// Returns an error if the HTTP client cannot be built, if the request fails,
104/// if the server responds with a non-success status, if `dest` cannot be
105/// created, or if writing the body to disk fails.
106pub 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}