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
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 redirect_url = format!("https://github.com/{GITHUB_REPO}/releases/latest");
22 let response = ureq::get(&redirect_url)
23 .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
24 .call()
25 .context("failed to check latest release — check your internet connection")?;
26
27 let final_url = response.get_uri().to_string();
28 let tag_segment = final_url
29 .rsplit('/')
30 .next()
31 .context("could not extract version from GitHub redirect")?;
32 let latest = tag_segment.strip_prefix('v').unwrap_or(tag_segment);
33
34 let download_url = format!(
36 "https://github.com/{GITHUB_REPO}/releases/download/v{latest}/{asset_name}"
37 );
38
39 Ok(UpdateStatus {
40 current_version: CURRENT_VERSION.to_string(),
41 latest_version: latest.to_string(),
42 update_available: version_gt(latest, CURRENT_VERSION),
43 download_url,
44 })
45}
46
47pub fn perform_update() -> Result<()> {
49 if is_npm_managed() {
51 bail!(
52 "This binary is managed by npm.\n\
53 Run `npm update -g cc-token-usage` to upgrade,\n\
54 or use `npx cc-token-usage@latest` to always run the latest version."
55 );
56 }
57
58 check_write_permission()?;
61
62 let status = check_for_update()?;
63
64 if !status.update_available {
65 eprintln!("Already up to date (v{})", status.current_version);
66 return Ok(());
67 }
68
69 eprintln!(
70 "Updating v{} → v{}",
71 status.current_version, status.latest_version
72 );
73 eprintln!("Downloading...");
74
75 let data = ureq::get(&status.download_url)
77 .header("User-Agent", concat!("cc-token-usage/", env!("CARGO_PKG_VERSION")))
78 .call()
79 .context("failed to download release")?
80 .into_body()
81 .read_to_vec()
82 .context("failed to read response body")?;
83
84 let decoder = flate2::read::GzDecoder::new(&data[..]);
86 let mut archive = tar::Archive::new(decoder);
87
88 let current_exe = std::env::current_exe().context("cannot determine current executable path")?;
89 let parent = current_exe
90 .parent()
91 .context("current executable has no parent directory")?;
92 let tmp_path = parent.join(".cc-token-usage.new");
93
94 let mut found = false;
95 for entry in archive.entries().context("failed to read tar archive")? {
96 let mut entry = entry.context("corrupt tar entry")?;
97 let path = entry.path().context("invalid path in archive")?;
98 if path.file_name().and_then(|n| n.to_str()) == Some("cc-token-usage") {
99 entry
100 .unpack(&tmp_path)
101 .context("failed to extract binary")?;
102 found = true;
103 break;
104 }
105 }
106
107 if !found {
108 let _ = std::fs::remove_file(&tmp_path);
110 bail!("archive does not contain cc-token-usage binary");
111 }
112
113 #[cfg(unix)]
115 {
116 use std::os::unix::fs::PermissionsExt;
117 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
118 .context("failed to set executable permission")?;
119 }
120
121 let backup_path = parent.join(".cc-token-usage.old");
123 let _ = std::fs::remove_file(&backup_path); std::fs::rename(¤t_exe, &backup_path).context(
126 "failed to replace binary — permission denied?\n\
127 Try: sudo cc-token-usage update",
128 )?;
129
130 if let Err(e) = std::fs::rename(&tmp_path, ¤t_exe) {
131 let _ = std::fs::rename(&backup_path, ¤t_exe);
133 let _ = std::fs::remove_file(&tmp_path);
134 return Err(e).context("failed to install new binary (rolled back)");
135 }
136
137 let _ = std::fs::remove_file(&backup_path);
139
140 eprintln!("Updated to v{}", status.latest_version);
141 Ok(())
142}
143
144fn target_triple() -> &'static str {
146 cfg_if_triple()
147}
148
149#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
150fn cfg_if_triple() -> &'static str {
151 "aarch64-apple-darwin"
152}
153#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
154fn cfg_if_triple() -> &'static str {
155 "x86_64-apple-darwin"
156}
157#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
158fn cfg_if_triple() -> &'static str {
159 "x86_64-unknown-linux-musl"
160}
161#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
162fn cfg_if_triple() -> &'static str {
163 "aarch64-unknown-linux-musl"
164}
165#[cfg(not(any(
166 all(target_os = "macos", target_arch = "aarch64"),
167 all(target_os = "macos", target_arch = "x86_64"),
168 all(target_os = "linux", target_arch = "x86_64"),
169 all(target_os = "linux", target_arch = "aarch64"),
170)))]
171fn cfg_if_triple() -> &'static str {
172 "unsupported"
173}
174
175fn is_npm_managed() -> bool {
177 std::env::current_exe()
178 .ok()
179 .and_then(|p| p.to_str().map(|s| s.contains("node_modules")))
180 .unwrap_or(false)
181}
182
183fn check_write_permission() -> Result<()> {
186 let exe = std::env::current_exe().context("cannot determine executable path")?;
187 let dir = exe.parent().context("executable has no parent directory")?;
188
189 match tempfile::tempfile_in(dir) {
190 Ok(_) => Ok(()),
191 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
192 bail!(
193 "Cannot update: no write permission to {}.\n\
194 This binary may have been installed by a package manager.\n\
195 Try: sudo cc-token-usage update\n\
196 Or reinstall with: curl -fsSL https://raw.githubusercontent.com/LokiQ0713/cc-token-usage/master/install.sh | sh",
197 dir.display()
198 );
199 }
200 Err(e) => Err(e).context("permission check failed"),
201 }
202}
203
204fn version_gt(a: &str, b: &str) -> bool {
206 let parse = |s: &str| -> (u32, u32, u32) {
207 let mut parts = s.split('.').filter_map(|p| p.parse().ok());
208 (
209 parts.next().unwrap_or(0),
210 parts.next().unwrap_or(0),
211 parts.next().unwrap_or(0),
212 )
213 };
214 parse(a) > parse(b)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_version_gt() {
223 assert!(version_gt("1.5.0", "1.4.0"));
224 assert!(version_gt("2.0.0", "1.99.99"));
225 assert!(version_gt("1.4.1", "1.4.0"));
226 assert!(!version_gt("1.4.0", "1.4.0"));
227 assert!(!version_gt("1.3.0", "1.4.0"));
228 }
229
230 #[test]
231 fn test_target_triple_is_known() {
232 assert_ne!(target_triple(), "unsupported");
233 }
234
235 #[test]
236 fn test_npm_detection() {
237 assert!(!is_npm_managed());
239 }
240}