use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use super::Tool;
const GITHUB_REPO: &str = "stellar/procyon";
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct GitHubRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
assets: Vec<GitHubAsset>,
}
#[derive(Debug, Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
size: u64,
}
pub struct CheckUpdateTool;
#[async_trait]
impl Tool for CheckUpdateTool {
fn name(&self) -> &str {
"check_update"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Check for available updates"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(&self, _input: Value) -> Result<String, String> {
let client = reqwest::Client::new();
let response = client
.get(format!(
"https://api.github.com/repos/{}/releases/latest",
GITHUB_REPO
))
.header("accept", "application/vnd.github.v3+json")
.header("user-agent", "procyon")
.send()
.await
.map_err(|e| format!("Failed to check for updates: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to check for updates: HTTP {}",
response.status()
));
}
let release: GitHubRelease = response
.json()
.await
.map_err(|e| format!("Failed to parse release: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let latest_version = release.tag_name.trim_start_matches('v');
let mut output = format!(
"Current version: {}\nLatest version: {}\n",
current_version, latest_version
);
if !is_newer(latest_version, current_version) {
output.push_str("\nYou are up to date!");
} else {
output.push_str(&format!(
"\nUpdate available: {} -> {}\n",
current_version, latest_version
));
if let Some(body) = &release.body {
output.push_str(&format!("\nRelease notes:\n{}\n", body));
}
let platform = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let binary_name = format!("procyon-{}-{}", platform, arch);
let asset = release
.assets
.iter()
.find(|a| a.name.contains(&binary_name));
if let Some(asset) = asset {
output.push_str(&format!(
"\nDownload: {}\nSize: {} bytes",
asset.browser_download_url, asset.size
));
}
}
Ok(output)
}
}
fn is_newer(candidate: &str, current: &str) -> bool {
fn parts(version: &str) -> Vec<u64> {
version
.split(['-', '+'])
.next()
.unwrap_or(version)
.split('.')
.map(|p| p.parse().unwrap_or(0))
.collect()
}
let (candidate, current) = (parts(candidate), parts(current));
for i in 0..candidate.len().max(current.len()) {
let (a, b) = (
candidate.get(i).copied().unwrap_or(0),
current.get(i).copied().unwrap_or(0),
);
if a != b {
return a > b;
}
}
false
}
#[cfg(test)]
mod tests {
use super::is_newer;
#[test]
fn detects_a_real_upgrade() {
assert!(is_newer("0.2.0", "0.1.9"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(is_newer("0.1.10", "0.1.9"));
}
#[test]
fn does_not_announce_a_downgrade() {
assert!(!is_newer("0.1", "0.1.0"));
assert!(!is_newer("0.1.9", "0.2.0"));
assert!(!is_newer("0.9.9", "1.0.0"));
}
#[test]
fn equal_versions_are_not_newer() {
assert!(!is_newer("0.1.0", "0.1.0"));
assert!(!is_newer("0.1.0", "0.1"));
}
#[test]
fn tolerates_prerelease_suffixes() {
assert!(!is_newer("0.1.0-rc.1", "0.1.0"));
assert!(is_newer("0.2.0-beta", "0.1.0"));
}
}