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};
9
10/// GitHub REST endpoint for the repository's latest release.
11const GITHUB_API_RELEASES_URL: &str =
12    "https://api.github.com/repos/Mai0313/VibeCodingTracker/releases/latest";
13/// `User-Agent` header value (`<crate>/<version>`), required by the GitHub API.
14// Pinned to the product name (not the crate name, which is `vct-core`) so the
15// GitHub API sees a stable identifier across the workspace rename.
16const USER_AGENT: &str = concat!("vibe_coding_tracker/", env!("CARGO_PKG_VERSION"));
17
18/// A GitHub release, deserialized from the Releases API.
19#[derive(Debug, Deserialize, Serialize)]
20pub struct GitHubRelease {
21    /// Git tag the release points at (e.g. `"v0.1.6"`).
22    pub tag_name: String,
23    /// Human-readable release title.
24    pub name: String,
25    /// Release notes body, absent when the release has none.
26    pub body: Option<String>,
27    /// Downloadable assets attached to the release.
28    pub assets: Vec<GitHubAsset>,
29}
30
31/// A single downloadable file attached to a [`GitHubRelease`].
32#[derive(Debug, Deserialize, Serialize)]
33pub struct GitHubAsset {
34    /// Asset file name, matched against the platform pattern.
35    pub name: String,
36    /// Direct download URL for the asset.
37    pub browser_download_url: String,
38    /// Asset size in bytes.
39    pub size: u64,
40}
41
42/// Fetches the repository's latest release from the GitHub API.
43///
44/// # Errors
45///
46/// Returns an error if the HTTP client cannot be built, if the request fails,
47/// if GitHub responds with a non-success status, or if the response body is
48/// not the expected release JSON.
49pub fn fetch_latest_release() -> Result<GitHubRelease> {
50    fetch_latest_release_from(GITHUB_API_RELEASES_URL)
51}
52
53/// Fetches the latest release from an explicit endpoint URL.
54///
55/// The injectable counterpart of [`fetch_latest_release`]: production passes the
56/// real GitHub endpoint, tests point `url` at a local mock server so no real API
57/// is reached.
58///
59/// # Errors
60///
61/// Returns an error if the HTTP client cannot be built, if the request fails,
62/// if the server responds with a non-success status, or if the response body is
63/// not the expected release JSON.
64pub fn fetch_latest_release_from(url: &str) -> Result<GitHubRelease> {
65    let client = reqwest::blocking::Client::builder()
66        .user_agent(USER_AGENT)
67        .build()
68        .context("Failed to create HTTP client")?;
69
70    let response = client
71        .get(url)
72        .send()
73        .context("Failed to fetch release information from GitHub")?;
74
75    if !response.status().is_success() {
76        anyhow::bail!("GitHub API returned error status: {}", response.status());
77    }
78
79    let release: GitHubRelease = response
80        .json()
81        .context("Failed to parse GitHub release JSON")?;
82
83    Ok(release)
84}
85
86/// Downloads the file at `url` and writes it to `dest`.
87///
88/// Streams the response body straight to the destination file rather than
89/// buffering it in memory.
90///
91/// # Errors
92///
93/// Returns an error if the HTTP client cannot be built, if the request fails,
94/// if the server responds with a non-success status, if `dest` cannot be
95/// created, or if writing the body to disk fails.
96pub fn download_file(url: &str, dest: &std::path::Path) -> Result<()> {
97    let client = reqwest::blocking::Client::builder()
98        .user_agent(USER_AGENT)
99        .build()
100        .context("Failed to create HTTP client")?;
101
102    let mut response = client.get(url).send().context("Failed to download file")?;
103
104    if !response.status().is_success() {
105        anyhow::bail!("Download failed with status: {}", response.status());
106    }
107
108    let mut file = std::fs::File::create(dest)
109        .context(format!("Failed to create file: {}", dest.display()))?;
110
111    response
112        .copy_to(&mut file)
113        .context("Failed to write downloaded content to file")?;
114
115    Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use httpmock::prelude::*;
122    use serde_json::json;
123
124    #[test]
125    fn fetch_latest_release_parses_mock_response() {
126        let server = MockServer::start();
127        let endpoint = server.mock(|when, then| {
128            when.method(GET).path("/releases/latest");
129            then.status(200).json_body(json!({
130                "tag_name": "v1.2.3",
131                "name": "Release 1.2.3",
132                "body": "notes",
133                "assets": [
134                    {
135                        "name": "vct-linux-x64.tar.gz",
136                        "browser_download_url": "https://example.test/vct-linux-x64.tar.gz",
137                        "size": 42
138                    }
139                ]
140            }));
141        });
142
143        let release = fetch_latest_release_from(&server.url("/releases/latest"))
144            .expect("should parse the release JSON");
145        endpoint.assert();
146        assert_eq!(release.tag_name, "v1.2.3");
147        assert_eq!(release.assets.len(), 1);
148        assert_eq!(release.assets[0].name, "vct-linux-x64.tar.gz");
149        assert_eq!(release.assets[0].size, 42);
150    }
151
152    #[test]
153    fn fetch_latest_release_errors_on_non_success() {
154        let server = MockServer::start();
155        server.mock(|when, then| {
156            when.method(GET).path("/releases/latest");
157            then.status(404);
158        });
159        assert!(fetch_latest_release_from(&server.url("/releases/latest")).is_err());
160    }
161
162    #[test]
163    fn download_file_streams_body_to_disk() {
164        let server = MockServer::start();
165        server.mock(|when, then| {
166            when.method(GET).path("/asset.bin");
167            then.status(200).body("binary-contents");
168        });
169        let dir = tempfile::tempdir().unwrap();
170        let dest = dir.path().join("asset.bin");
171
172        download_file(&server.url("/asset.bin"), &dest).expect("download should succeed");
173        assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-contents");
174    }
175
176    #[test]
177    fn download_file_errors_on_non_success() {
178        let server = MockServer::start();
179        server.mock(|when, then| {
180            when.method(GET).path("/missing.bin");
181            then.status(500);
182        });
183        let dir = tempfile::tempdir().unwrap();
184        let dest = dir.path().join("missing.bin");
185        assert!(download_file(&server.url("/missing.bin"), &dest).is_err());
186    }
187}