use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const GITHUB_API_RELEASES_URL: &str =
"https://api.github.com/repos/Mai0313/VibeCodingTracker/releases/latest";
const USER_AGENT: &str = concat!("vibe_coding_tracker/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Deserialize, Serialize)]
pub struct GitHubRelease {
pub tag_name: String,
pub name: String,
pub body: Option<String>,
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}
pub fn fetch_latest_release() -> Result<GitHubRelease> {
fetch_latest_release_from(GITHUB_API_RELEASES_URL)
}
pub fn fetch_latest_release_from(url: &str) -> Result<GitHubRelease> {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(url)
.send()
.context("Failed to fetch release information from GitHub")?;
if !response.status().is_success() {
anyhow::bail!("GitHub API returned error status: {}", response.status());
}
let release: GitHubRelease = response
.json()
.context("Failed to parse GitHub release JSON")?;
Ok(release)
}
pub fn download_file(url: &str, dest: &std::path::Path) -> Result<()> {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.context("Failed to create HTTP client")?;
let mut response = client.get(url).send().context("Failed to download file")?;
if !response.status().is_success() {
anyhow::bail!("Download failed with status: {}", response.status());
}
let mut file = std::fs::File::create(dest)
.context(format!("Failed to create file: {}", dest.display()))?;
response
.copy_to(&mut file)
.context("Failed to write downloaded content to file")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
use serde_json::json;
#[test]
fn fetch_latest_release_parses_mock_response() {
let server = MockServer::start();
let endpoint = server.mock(|when, then| {
when.method(GET).path("/releases/latest");
then.status(200).json_body(json!({
"tag_name": "v1.2.3",
"name": "Release 1.2.3",
"body": "notes",
"assets": [
{
"name": "vct-linux-x64.tar.gz",
"browser_download_url": "https://example.test/vct-linux-x64.tar.gz",
"size": 42
}
]
}));
});
let release = fetch_latest_release_from(&server.url("/releases/latest"))
.expect("should parse the release JSON");
endpoint.assert();
assert_eq!(release.tag_name, "v1.2.3");
assert_eq!(release.assets.len(), 1);
assert_eq!(release.assets[0].name, "vct-linux-x64.tar.gz");
assert_eq!(release.assets[0].size, 42);
}
#[test]
fn fetch_latest_release_errors_on_non_success() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/releases/latest");
then.status(404);
});
assert!(fetch_latest_release_from(&server.url("/releases/latest")).is_err());
}
#[test]
fn download_file_streams_body_to_disk() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/asset.bin");
then.status(200).body("binary-contents");
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("asset.bin");
download_file(&server.url("/asset.bin"), &dest).expect("download should succeed");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-contents");
}
#[test]
fn download_file_errors_on_non_success() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/missing.bin");
then.status(500);
});
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("missing.bin");
assert!(download_file(&server.url("/missing.bin"), &dest).is_err());
}
}