Skip to main content

ccs/
update.rs

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
9/// Map OS/arch to cargo-dist release artifact target triple.
10fn 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
29/// Check if the binary is managed by Homebrew.
30fn is_homebrew_install(exe_path: &Path) -> bool {
31    let path_str = exe_path.to_string_lossy();
32    path_str.contains("/Cellar/")
33}
34
35/// Fetch the latest release tag from GitHub API using curl.
36fn 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
64/// Download a URL to a file path using curl.
65fn 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
86/// Extract a tar.gz archive into a directory.
87fn 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
102/// Compute SHA-256 hash of a file using system tools.
103fn sha256_of(path: &Path) -> Result<String, String> {
104    // Try sha256sum first (common on Linux)
105    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    // Fall back to shasum -a 256 (macOS)
115    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
132/// Verify SHA-256 checksum of a file.
133fn 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
148/// Replace the current binary with the new one, with rollback on failure.
149fn 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    // Copy to destination directory to avoid EXDEV (cross-device rename)
155    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    // Rename current -> .old, then staged -> current
166    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        // Rollback: restore the original
172        let _ = std::fs::rename(&backup, current_exe);
173        return Err(format!("Failed to install new binary (rolled back): {e}"));
174    }
175
176    // Cleanup
177    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
193/// Run the self-update process.
194pub 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    // Guard: Homebrew-managed installs (canonicalize to resolve symlinks)
199    let canonical_exe = std::fs::canonicalize(&current_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    // cargo-dist artifact naming: ccfullsearch-{target}.tar.gz
206    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    // cargo-dist extracts into a subdirectory named after the artifact
251    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        // Fallback: binary directly in extract dir
256        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}