Skip to main content

a3s_updater/
lib.rs

1//! Self-update library for A3S CLI binaries via GitHub Releases.
2//!
3//! Each binary provides an [`UpdateConfig`] describing itself, then calls
4//! [`run_update`] to check for a newer release, download the matching
5//! platform asset, and replace the running binary in-place.
6
7mod download;
8mod github;
9mod install;
10mod platform;
11
12pub use github::Release;
13
14/// Configuration for the update check — each binary provides its own.
15pub struct UpdateConfig {
16    /// Name of the binary file (e.g. `"a3s-code"`).
17    pub binary_name: &'static str,
18    /// Crate name on crates.io (for `cargo install` fallback message).
19    pub crate_name: &'static str,
20    /// Current version string, typically `env!("CARGO_PKG_VERSION")`.
21    pub current_version: &'static str,
22    /// GitHub repository owner (e.g. `"A3S-Lab"`).
23    pub github_owner: &'static str,
24    /// GitHub repository name (e.g. `"Code"`).
25    pub github_repo: &'static str,
26}
27
28/// Run the full update flow: check -> download -> replace.
29pub async fn run_update(config: &UpdateConfig) -> anyhow::Result<()> {
30    println!("Checking for updates...");
31
32    let (os, arch) = platform::platform_target()?;
33    let release = github::fetch_latest_release(config.github_owner, config.github_repo).await?;
34    let latest_version = github::parse_version(&release.tag_name)?;
35    let current_version = semver::Version::parse(config.current_version).map_err(|e| {
36        anyhow::anyhow!(
37            "Failed to parse current version '{}': {}",
38            config.current_version,
39            e
40        )
41    })?;
42
43    println!("Current version: {}", current_version);
44    println!("Latest version:  {}", latest_version);
45
46    if current_version >= latest_version {
47        println!("\nAlready up to date (v{}).", current_version);
48        return Ok(());
49    }
50
51    let asset = match github::find_matching_asset(&release, config.binary_name, &os, &arch) {
52        Some(a) => a,
53        None => {
54            println!("\nNo pre-built binary found for {}-{}.", os, arch);
55            println!("Run manually: cargo install {}", config.crate_name);
56            return Ok(());
57        }
58    };
59
60    println!(
61        "\nDownloading {} v{} ({}-{})...",
62        config.binary_name, latest_version, os, arch
63    );
64
65    let new_binary =
66        download::download_and_extract(&asset.browser_download_url, config.binary_name).await?;
67    install::replace_binary(&new_binary)?;
68
69    println!(
70        "\nUpdated {}: {} -> {}",
71        config.binary_name, current_version, latest_version
72    );
73
74    Ok(())
75}
76
77// ---------------------------------------------------------------------------
78// Tests
79// ---------------------------------------------------------------------------
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_platform_detection() {
87        let result = platform::platform_target();
88        assert!(result.is_ok(), "platform_target() should succeed");
89        let (os, arch) = result.unwrap();
90        assert!(
91            os == "darwin" || os == "linux",
92            "OS should be darwin or linux, got: {}",
93            os
94        );
95        assert!(
96            arch == "arm64" || arch == "x86_64",
97            "Arch should be arm64 or x86_64, got: {}",
98            arch
99        );
100    }
101
102    #[test]
103    fn test_asset_name_generation() {
104        let name = github::asset_name("a3s-code", "0.3.0", "darwin", "arm64");
105        assert_eq!(name, "a3s-code-0.3.0-darwin-arm64.tar.gz");
106    }
107
108    #[test]
109    fn test_version_compare_newer() {
110        let current = semver::Version::parse("0.1.0").unwrap();
111        let latest = semver::Version::parse("0.2.0").unwrap();
112        assert!(current < latest, "0.1.0 should be less than 0.2.0");
113    }
114
115    #[test]
116    fn test_version_compare_same() {
117        let current = semver::Version::parse("0.2.0").unwrap();
118        let latest = semver::Version::parse("0.2.0").unwrap();
119        assert!(current >= latest, "0.2.0 should be >= 0.2.0");
120    }
121
122    #[test]
123    fn test_version_compare_older() {
124        let current = semver::Version::parse("0.3.0").unwrap();
125        let latest = semver::Version::parse("0.2.0").unwrap();
126        assert!(current >= latest, "0.3.0 should be >= 0.2.0");
127    }
128
129    #[test]
130    fn test_parse_github_release_json() {
131        let json = serde_json::json!({
132            "tag_name": "v0.3.0",
133            "body": "Bug fixes and improvements",
134            "assets": [
135                {
136                    "name": "a3s-code-0.3.0-darwin-arm64.tar.gz",
137                    "browser_download_url": "https://example.com/a3s-code-0.3.0-darwin-arm64.tar.gz"
138                },
139                {
140                    "name": "a3s-code-0.3.0-linux-x86_64.tar.gz",
141                    "browser_download_url": "https://example.com/a3s-code-0.3.0-linux-x86_64.tar.gz"
142                }
143            ]
144        });
145
146        let release: Release = serde_json::from_value(json).unwrap();
147        assert_eq!(release.tag_name, "v0.3.0");
148        assert_eq!(release.body.as_deref(), Some("Bug fixes and improvements"));
149        assert_eq!(release.assets.len(), 2);
150        assert_eq!(release.assets[0].name, "a3s-code-0.3.0-darwin-arm64.tar.gz");
151    }
152
153    #[test]
154    fn test_find_matching_asset() {
155        let release = Release {
156            tag_name: "v0.3.0".to_string(),
157            body: None,
158            assets: vec![
159                github::Asset {
160                    name: "a3s-code-0.3.0-darwin-arm64.tar.gz".to_string(),
161                    browser_download_url: "https://example.com/darwin-arm64.tar.gz".to_string(),
162                },
163                github::Asset {
164                    name: "a3s-code-0.3.0-linux-x86_64.tar.gz".to_string(),
165                    browser_download_url: "https://example.com/linux-x86_64.tar.gz".to_string(),
166                },
167            ],
168        };
169
170        // Should find darwin-arm64
171        let found = github::find_matching_asset(&release, "a3s-code", "darwin", "arm64");
172        assert!(found.is_some());
173        assert!(found.unwrap().name.contains("darwin-arm64"));
174
175        // Should find linux-x86_64
176        let found = github::find_matching_asset(&release, "a3s-code", "linux", "x86_64");
177        assert!(found.is_some());
178        assert!(found.unwrap().name.contains("linux-x86_64"));
179
180        // Should return None for missing platform
181        let found = github::find_matching_asset(&release, "a3s-code", "linux", "riscv64");
182        assert!(found.is_none());
183    }
184
185    #[test]
186    fn test_strip_version_prefix() {
187        let v1 = github::parse_version("v0.2.0").unwrap();
188        assert_eq!(v1, semver::Version::parse("0.2.0").unwrap());
189
190        let v2 = github::parse_version("0.2.0").unwrap();
191        assert_eq!(v2, semver::Version::parse("0.2.0").unwrap());
192    }
193}