procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
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)
    }
}

// String equality would announce any difference as an upgrade, including a downgrade such as
// "0.2.0 -> 0.1.9". Missing components count as 0, so "0.1" and "0.1.0" compare equal.
fn is_newer(candidate: &str, current: &str) -> bool {
    // The prerelease/build suffix is dropped before splitting, otherwise "0.1.0-rc.1" parses as
    // a fourth component and outranks the final "0.1.0".
    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"));
    }
}