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(
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 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
49pub fn perform_update() -> Result<()> {
51 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 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 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 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 let _ = std::fs::remove_file(&tmp_path);
116 bail!("archive does not contain cc-token-usage binary");
117 }
118
119 #[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 let backup_path = parent.join(".cc-token-usage.old");
129 let _ = std::fs::remove_file(&backup_path); std::fs::rename(¤t_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, ¤t_exe) {
137 let _ = std::fs::rename(&backup_path, ¤t_exe);
139 let _ = std::fs::remove_file(&tmp_path);
140 return Err(e).context("failed to install new binary (rolled back)");
141 }
142
143 let _ = std::fs::remove_file(&backup_path);
145
146 eprintln!("Updated to v{}", status.latest_version);
147 Ok(())
148}
149
150fn 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
181fn 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
189fn 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
210fn 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 assert!(!is_npm_managed());
245 }
246}