1use semver::Version;
2use std::path::Path;
3use std::process::Command;
4
5const REPO: &str = "materkey/ccfullsearch";
6const BIN_NAME: &str = "ccs";
7const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
8
9fn target_triple() -> Result<&'static str, String> {
11 match (std::env::consts::OS, std::env::consts::ARCH) {
12 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
13 ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
14 ("linux", arch) => linux_target_triple(arch, cfg!(target_env = "musl")),
15 (os, arch) => Err(format!("Unsupported platform: {os}/{arch}")),
16 }
17}
18
19fn linux_target_triple(arch: &str, musl: bool) -> Result<&'static str, String> {
20 match (arch, musl) {
21 ("x86_64", false) => Ok("x86_64-unknown-linux-gnu"),
22 ("x86_64", true) => Ok("x86_64-unknown-linux-musl"),
23 ("aarch64", false) => Ok("aarch64-unknown-linux-gnu"),
24 ("aarch64", true) => Ok("aarch64-unknown-linux-musl"),
25 (arch, _) => Err(format!("Unsupported platform: linux/{arch}")),
26 }
27}
28
29fn is_homebrew_install(exe_path: &Path) -> bool {
31 let path_str = exe_path.to_string_lossy();
32 path_str.contains("/Cellar/")
33}
34
35fn fetch_latest_version() -> Result<String, String> {
37 let output = Command::new("curl")
38 .args([
39 "-sSf",
40 "--connect-timeout",
41 "10",
42 "--max-time",
43 "30",
44 &format!("https://api.github.com/repos/{REPO}/releases/latest"),
45 ])
46 .output()
47 .map_err(|e| format!("Failed to run curl: {e}"))?;
48
49 if !output.status.success() {
50 let stderr = String::from_utf8_lossy(&output.stderr);
51 return Err(format!("Failed to fetch latest release: {}", stderr.trim()));
52 }
53
54 let body: serde_json::Value = serde_json::from_slice(&output.stdout)
55 .map_err(|e| format!("Failed to parse GitHub API response: {e}"))?;
56
57 let tag = body["tag_name"]
58 .as_str()
59 .ok_or("No tag_name in GitHub API response")?;
60
61 Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
62}
63
64fn download(url: &str, dest: &Path) -> Result<(), String> {
66 let status = Command::new("curl")
67 .args([
68 "-sSLf",
69 "--connect-timeout",
70 "10",
71 "--max-time",
72 "120",
73 "-o",
74 ])
75 .arg(dest)
76 .arg(url)
77 .status()
78 .map_err(|e| format!("Failed to run curl: {e}"))?;
79
80 if !status.success() {
81 return Err(format!("Download failed: {url}"));
82 }
83 Ok(())
84}
85
86fn extract_tar(archive: &Path, dest: &Path) -> Result<(), String> {
88 let status = Command::new("tar")
89 .arg("-xzf")
90 .arg(archive)
91 .arg("-C")
92 .arg(dest)
93 .status()
94 .map_err(|e| format!("Failed to run tar: {e}"))?;
95
96 if !status.success() {
97 return Err("Failed to extract archive".to_string());
98 }
99 Ok(())
100}
101
102fn sha256_of(path: &Path) -> Result<String, String> {
104 if let Ok(output) = Command::new("sha256sum").arg(path).output() {
106 if output.status.success() {
107 let out = String::from_utf8_lossy(&output.stdout);
108 if let Some(hash) = out.split_whitespace().next() {
109 return Ok(hash.to_string());
110 }
111 }
112 }
113
114 let output = Command::new("shasum")
116 .args(["-a", "256"])
117 .arg(path)
118 .output()
119 .map_err(|e| format!("Neither sha256sum nor shasum found: {e}"))?;
120
121 if !output.status.success() {
122 return Err("Checksum command failed".to_string());
123 }
124
125 let out = String::from_utf8_lossy(&output.stdout);
126 out.split_whitespace()
127 .next()
128 .map(|s| s.to_string())
129 .ok_or_else(|| "Could not parse checksum output".to_string())
130}
131
132fn verify_checksum(file: &Path, expected_content: &str) -> Result<(), String> {
134 let expected_hash = expected_content
135 .split_whitespace()
136 .next()
137 .ok_or("Invalid checksum file format")?;
138
139 let actual_hash = sha256_of(file)?;
140 if actual_hash != expected_hash {
141 return Err(format!(
142 "Checksum mismatch!\n Expected: {expected_hash}\n Got: {actual_hash}"
143 ));
144 }
145 Ok(())
146}
147
148fn replace_binary(new_binary: &Path, current_exe: &Path) -> Result<(), String> {
150 let exe_dir = current_exe
151 .parent()
152 .ok_or("Could not determine binary directory")?;
153
154 let staged = exe_dir.join(format!(".{BIN_NAME}.new"));
156 std::fs::copy(new_binary, &staged).map_err(|e| format!("Failed to copy new binary: {e}"))?;
157
158 #[cfg(unix)]
159 {
160 use std::os::unix::fs::PermissionsExt;
161 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
162 .map_err(|e| format!("Failed to set permissions: {e}"))?;
163 }
164
165 let backup = exe_dir.join(format!(".{BIN_NAME}.old"));
167 std::fs::rename(current_exe, &backup)
168 .map_err(|e| format!("Failed to move current binary aside: {e}"))?;
169
170 if let Err(e) = std::fs::rename(&staged, current_exe) {
171 let _ = std::fs::rename(&backup, current_exe);
173 return Err(format!("Failed to install new binary (rolled back): {e}"));
174 }
175
176 let _ = std::fs::remove_file(&backup);
178 Ok(())
179}
180
181fn compare_versions(
182 current_version: &str,
183 latest_version: &str,
184) -> Result<std::cmp::Ordering, String> {
185 let current = Version::parse(current_version)
186 .map_err(|e| format!("Invalid current version '{current_version}': {e}"))?;
187 let latest = Version::parse(latest_version)
188 .map_err(|e| format!("Invalid latest version '{latest_version}': {e}"))?;
189
190 Ok(current.cmp(&latest))
191}
192
193pub fn run() -> Result<(), String> {
195 let current_exe =
196 std::env::current_exe().map_err(|e| format!("Could not determine executable path: {e}"))?;
197
198 let canonical_exe = std::fs::canonicalize(¤t_exe).unwrap_or(current_exe.clone());
200 if is_homebrew_install(&canonical_exe) {
201 return Err("ccs is managed by Homebrew. Run `brew upgrade ccs` instead.".to_string());
202 }
203
204 let triple = target_triple()?;
205 let artifact_name = format!("ccfullsearch-{triple}");
207
208 eprintln!("Checking for updates...");
209
210 let latest_version = fetch_latest_version()?;
211
212 match compare_versions(CURRENT_VERSION, &latest_version)? {
213 std::cmp::Ordering::Equal => {
214 eprintln!("Already up to date (v{CURRENT_VERSION})");
215 return Ok(());
216 }
217 std::cmp::Ordering::Greater => {
218 eprintln!(
219 "Current build v{CURRENT_VERSION} is newer than latest release v{latest_version}"
220 );
221 return Ok(());
222 }
223 std::cmp::Ordering::Less => {}
224 }
225
226 eprintln!("Downloading v{latest_version}...");
227
228 let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp directory: {e}"))?;
229 let tar_path = tmp.path().join(format!("{artifact_name}.tar.gz"));
230 let sha_path = tmp.path().join(format!("{artifact_name}.tar.gz.sha256"));
231
232 let base_url = format!("https://github.com/{REPO}/releases/download/v{latest_version}");
233
234 download(&format!("{base_url}/{artifact_name}.tar.gz"), &tar_path)?;
235 download(
236 &format!("{base_url}/{artifact_name}.tar.gz.sha256"),
237 &sha_path,
238 )?;
239
240 eprintln!("Verifying checksum...");
241 let sha_content = std::fs::read_to_string(&sha_path)
242 .map_err(|e| format!("Failed to read checksum file: {e}"))?;
243 verify_checksum(&tar_path, &sha_content)?;
244
245 eprintln!("Installing...");
246 let extract_dir = tmp.path().join("extract");
247 std::fs::create_dir(&extract_dir).map_err(|e| format!("Failed to create extract dir: {e}"))?;
248 extract_tar(&tar_path, &extract_dir)?;
249
250 let new_binary = extract_dir.join(&artifact_name).join(BIN_NAME);
252 let new_binary = if new_binary.exists() {
253 new_binary
254 } else {
255 let flat = extract_dir.join(BIN_NAME);
257 if flat.exists() {
258 flat
259 } else {
260 return Err(format!(
261 "Extracted archive does not contain '{BIN_NAME}' binary"
262 ));
263 }
264 };
265
266 replace_binary(&new_binary, &canonical_exe)?;
267
268 eprintln!("Updated ccs v{CURRENT_VERSION} -> v{latest_version}");
269 Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[cfg(not(windows))]
277 #[test]
278 fn target_triple_returns_known_value() {
279 let triple = target_triple().unwrap();
280 assert!(
281 [
282 "aarch64-apple-darwin",
283 "x86_64-apple-darwin",
284 "x86_64-unknown-linux-gnu",
285 "aarch64-unknown-linux-gnu",
286 "x86_64-unknown-linux-musl",
287 "aarch64-unknown-linux-musl",
288 ]
289 .contains(&triple),
290 "Unexpected triple: {triple}"
291 );
292 }
293
294 #[cfg(windows)]
295 #[test]
296 fn target_triple_is_unsupported_on_windows() {
297 assert!(target_triple().is_err());
298 }
299
300 #[test]
301 fn linux_target_triple_preserves_gnu_assets() {
302 assert_eq!(
303 linux_target_triple("x86_64", false).unwrap(),
304 "x86_64-unknown-linux-gnu"
305 );
306 assert_eq!(
307 linux_target_triple("aarch64", false).unwrap(),
308 "aarch64-unknown-linux-gnu"
309 );
310 }
311
312 #[test]
313 fn linux_target_triple_selects_musl_assets() {
314 assert_eq!(
315 linux_target_triple("x86_64", true).unwrap(),
316 "x86_64-unknown-linux-musl"
317 );
318 assert_eq!(
319 linux_target_triple("aarch64", true).unwrap(),
320 "aarch64-unknown-linux-musl"
321 );
322 }
323
324 #[test]
325 fn is_homebrew_cellar() {
326 assert!(is_homebrew_install(Path::new(
327 "/opt/homebrew/Cellar/ccs/0.5.0/bin/ccs"
328 )));
329 }
330
331 #[test]
332 fn is_not_homebrew_cargo_home() {
333 assert!(!is_homebrew_install(Path::new(
334 "/Users/user/.cargo/bin/ccs"
335 )));
336 }
337
338 #[test]
339 fn is_not_homebrew_local_bin() {
340 assert!(!is_homebrew_install(Path::new("/usr/local/bin/ccs")));
341 }
342
343 #[test]
344 fn compare_versions_detects_equal_versions() {
345 assert_eq!(
346 compare_versions("0.5.0", "0.5.0").unwrap(),
347 std::cmp::Ordering::Equal
348 );
349 }
350
351 #[test]
352 fn compare_versions_detects_newer_local_builds() {
353 assert_eq!(
354 compare_versions("0.5.1-dev.0", "0.5.0").unwrap(),
355 std::cmp::Ordering::Greater
356 );
357 }
358
359 #[test]
360 fn compare_versions_detects_older_local_builds() {
361 assert_eq!(
362 compare_versions("0.5.0", "0.5.1").unwrap(),
363 std::cmp::Ordering::Less
364 );
365 }
366}