Skip to main content

cc_token_usage/
update.rs

1use anyhow::{bail, Context, Result};
2
3const GITHUB_API_URL: &str =
4    "https://api.github.com/repos/LokiQ0713/cc-token-usage/releases/latest";
5const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
6
7pub struct UpdateStatus {
8    pub current_version: String,
9    pub latest_version: String,
10    pub update_available: bool,
11    pub download_url: Option<String>,
12}
13
14/// Check for updates by querying GitHub Releases API.
15pub fn check_for_update() -> Result<UpdateStatus> {
16    let target = target_triple();
17    let asset_name = format!("cc-token-usage-{target}.tar.gz");
18
19    let mut req = ureq::get(GITHUB_API_URL)
20        .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
21        .header("Accept", "application/vnd.github.v3+json");
22
23    // Support GITHUB_TOKEN to avoid rate limiting (same pattern as mise)
24    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
25        req = req.header("Authorization", &format!("Bearer {token}"));
26    }
27
28    let response = req.call().map_err(|e| {
29            if let ureq::Error::StatusCode(403) = &e {
30                anyhow::anyhow!(
31                    "GitHub API rate limit exceeded.\n\
32                     Try again in a few minutes, or download from:\n\
33                     https://github.com/LokiQ0713/cc-token-usage/releases/latest"
34                )
35            } else {
36                anyhow::anyhow!("failed to query GitHub API — check your internet connection: {e}")
37            }
38        })?;
39
40    let body = response
41        .into_body()
42        .read_to_string()
43        .context("failed to read GitHub API response")?;
44
45    let resp: serde_json::Value =
46        serde_json::from_str(&body).context("failed to parse GitHub API response")?;
47
48    let tag = resp["tag_name"]
49        .as_str()
50        .context("GitHub API response missing tag_name")?;
51    let latest = tag.strip_prefix('v').unwrap_or(tag);
52
53    let download_url = resp["assets"]
54        .as_array()
55        .and_then(|assets| {
56            assets.iter().find_map(|a| {
57                if a["name"].as_str() == Some(asset_name.as_str()) {
58                    a["browser_download_url"].as_str().map(String::from)
59                } else {
60                    None
61                }
62            })
63        });
64
65    Ok(UpdateStatus {
66        current_version: CURRENT_VERSION.to_string(),
67        latest_version: latest.to_string(),
68        update_available: version_gt(latest, CURRENT_VERSION),
69        download_url,
70    })
71}
72
73/// Download the latest release and replace the current binary.
74pub fn perform_update() -> Result<()> {
75    // Refuse to update if managed by npm
76    if is_npm_managed() {
77        bail!(
78            "This binary is managed by npm.\n\
79             Run `npm update -g cc-token-usage` to upgrade,\n\
80             or use `npx cc-token-usage@latest` to always run the latest version."
81        );
82    }
83
84    // Rustup-style permission probe: try creating a tempdir next to the binary.
85    // If it fails, a package manager likely owns this location.
86    check_write_permission()?;
87
88    let status = check_for_update()?;
89
90    if !status.update_available {
91        eprintln!("Already up to date (v{})", status.current_version);
92        return Ok(());
93    }
94
95    let url = status.download_url.context(format!(
96        "No pre-built binary for this platform ({}).\n\
97         Use `cargo install cc-token-usage` instead.",
98        target_triple()
99    ))?;
100
101    eprintln!(
102        "Updating v{} → v{}",
103        status.current_version, status.latest_version
104    );
105    eprintln!("Downloading...");
106
107    // Download tar.gz
108    let data = ureq::get(&url)
109        .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
110        .call()
111        .context("failed to download release")?
112        .into_body()
113        .read_to_vec()
114        .context("failed to read response body")?;
115
116    // Extract binary from tar.gz
117    let decoder = flate2::read::GzDecoder::new(&data[..]);
118    let mut archive = tar::Archive::new(decoder);
119
120    let current_exe = std::env::current_exe().context("cannot determine current executable path")?;
121    let parent = current_exe
122        .parent()
123        .context("current executable has no parent directory")?;
124    let tmp_path = parent.join(".cc-token-usage.new");
125
126    let mut found = false;
127    for entry in archive.entries().context("failed to read tar archive")? {
128        let mut entry = entry.context("corrupt tar entry")?;
129        let path = entry.path().context("invalid path in archive")?;
130        if path.file_name().and_then(|n| n.to_str()) == Some("cc-token-usage") {
131            entry
132                .unpack(&tmp_path)
133                .context("failed to extract binary")?;
134            found = true;
135            break;
136        }
137    }
138
139    if !found {
140        // Clean up temp file
141        let _ = std::fs::remove_file(&tmp_path);
142        bail!("archive does not contain cc-token-usage binary");
143    }
144
145    // Set executable permission
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::PermissionsExt;
149        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
150            .context("failed to set executable permission")?;
151    }
152
153    // Atomic replacement: current → .old, .new → current
154    let backup_path = parent.join(".cc-token-usage.old");
155    let _ = std::fs::remove_file(&backup_path); // clean up any leftover
156
157    std::fs::rename(&current_exe, &backup_path).context(
158        "failed to replace binary — permission denied?\n\
159         Try: sudo cc-token-usage update",
160    )?;
161
162    if let Err(e) = std::fs::rename(&tmp_path, &current_exe) {
163        // Rollback: restore the old binary
164        let _ = std::fs::rename(&backup_path, &current_exe);
165        let _ = std::fs::remove_file(&tmp_path);
166        return Err(e).context("failed to install new binary (rolled back)");
167    }
168
169    // Best-effort cleanup
170    let _ = std::fs::remove_file(&backup_path);
171
172    eprintln!("Updated to v{}", status.latest_version);
173    Ok(())
174}
175
176/// Compile-time platform detection → target triple matching GitHub Release assets.
177fn target_triple() -> &'static str {
178    cfg_if_triple()
179}
180
181#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
182fn cfg_if_triple() -> &'static str {
183    "aarch64-apple-darwin"
184}
185#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
186fn cfg_if_triple() -> &'static str {
187    "x86_64-apple-darwin"
188}
189#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
190fn cfg_if_triple() -> &'static str {
191    "x86_64-unknown-linux-musl"
192}
193#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
194fn cfg_if_triple() -> &'static str {
195    "aarch64-unknown-linux-musl"
196}
197#[cfg(not(any(
198    all(target_os = "macos", target_arch = "aarch64"),
199    all(target_os = "macos", target_arch = "x86_64"),
200    all(target_os = "linux", target_arch = "x86_64"),
201    all(target_os = "linux", target_arch = "aarch64"),
202)))]
203fn cfg_if_triple() -> &'static str {
204    "unsupported"
205}
206
207/// Detect if the binary lives inside node_modules (npm-managed).
208fn is_npm_managed() -> bool {
209    std::env::current_exe()
210        .ok()
211        .and_then(|p| p.to_str().map(|s| s.contains("node_modules")))
212        .unwrap_or(false)
213}
214
215/// Rustup-style permission probe: try creating a temp file next to the binary.
216/// Catches cases where a package manager installed to /usr/local/bin, /opt/homebrew/bin, etc.
217fn check_write_permission() -> Result<()> {
218    let exe = std::env::current_exe().context("cannot determine executable path")?;
219    let dir = exe.parent().context("executable has no parent directory")?;
220
221    match tempfile::tempfile_in(dir) {
222        Ok(_) => Ok(()),
223        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
224            bail!(
225                "Cannot update: no write permission to {}.\n\
226                 This binary may have been installed by a package manager.\n\
227                 Try: sudo cc-token-usage update\n\
228                 Or reinstall with: curl -fsSL https://raw.githubusercontent.com/LokiQ0713/cc-token-usage/master/install.sh | sh",
229                dir.display()
230            );
231        }
232        Err(e) => Err(e).context("permission check failed"),
233    }
234}
235
236/// Simple semver comparison: returns true if `a` > `b`.
237fn version_gt(a: &str, b: &str) -> bool {
238    let parse = |s: &str| -> (u32, u32, u32) {
239        let mut parts = s.split('.').filter_map(|p| p.parse().ok());
240        (
241            parts.next().unwrap_or(0),
242            parts.next().unwrap_or(0),
243            parts.next().unwrap_or(0),
244        )
245    };
246    parse(a) > parse(b)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_version_gt() {
255        assert!(version_gt("1.5.0", "1.4.0"));
256        assert!(version_gt("2.0.0", "1.99.99"));
257        assert!(version_gt("1.4.1", "1.4.0"));
258        assert!(!version_gt("1.4.0", "1.4.0"));
259        assert!(!version_gt("1.3.0", "1.4.0"));
260    }
261
262    #[test]
263    fn test_target_triple_is_known() {
264        assert_ne!(target_triple(), "unsupported");
265    }
266
267    #[test]
268    fn test_npm_detection() {
269        // Current test binary is not in node_modules
270        assert!(!is_npm_managed());
271    }
272}