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
14pub 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 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
73pub fn perform_update() -> Result<()> {
75 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 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 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 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 let _ = std::fs::remove_file(&tmp_path);
142 bail!("archive does not contain cc-token-usage binary");
143 }
144
145 #[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 let backup_path = parent.join(".cc-token-usage.old");
155 let _ = std::fs::remove_file(&backup_path); std::fs::rename(¤t_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, ¤t_exe) {
163 let _ = std::fs::rename(&backup_path, ¤t_exe);
165 let _ = std::fs::remove_file(&tmp_path);
166 return Err(e).context("failed to install new binary (rolled back)");
167 }
168
169 let _ = std::fs::remove_file(&backup_path);
171
172 eprintln!("Updated to v{}", status.latest_version);
173 Ok(())
174}
175
176fn 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
207fn 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
215fn 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
236fn 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 assert!(!is_npm_managed());
271 }
272}