Skip to main content

cc_token_usage/
update.rs

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