vct_core/update/
github.rs1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10const GITHUB_API_RELEASES_URL: &str =
12 "https://api.github.com/repos/Mai0313/VibeCodingTracker/releases/latest";
13const USER_AGENT: &str = concat!("vibe_coding_tracker/", env!("CARGO_PKG_VERSION"));
17
18#[derive(Debug, Deserialize, Serialize)]
20pub struct GitHubRelease {
21 pub tag_name: String,
23 pub name: String,
25 pub body: Option<String>,
27 pub assets: Vec<GitHubAsset>,
29}
30
31#[derive(Debug, Deserialize, Serialize)]
33pub struct GitHubAsset {
34 pub name: String,
36 pub browser_download_url: String,
38 pub size: u64,
40}
41
42pub fn fetch_latest_release() -> Result<GitHubRelease> {
50 fetch_latest_release_from(GITHUB_API_RELEASES_URL)
51}
52
53pub 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
86pub 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}