Skip to main content

roboticus_cli/cli/update/
update_binary.rs

1//! Binary self-update logic: version checking, download, cargo build, installation.
2
3use std::io;
4use std::path::{Path, PathBuf};
5
6#[cfg(windows)]
7use std::os::windows::process::CommandExt;
8
9use serde::Deserialize;
10
11use super::{
12    CRATE_NAME, CRATES_IO_API, GITHUB_RELEASES_API, RELEASE_BASE_URL, UpdateState, bytes_sha256,
13    colors, confirm_action, icons, is_newer, now_iso,
14};
15use crate::cli::{CRT_DRAW_MS, heading, theme};
16
17// ── Version comparison ───────────────────────────────────────
18
19pub(super) fn parse_semver(v: &str) -> (u32, u32, u32) {
20    let v = v.trim_start_matches('v');
21    let v = v.split_once('+').map(|(core, _)| core).unwrap_or(v);
22    let v = v.split_once('-').map(|(core, _)| core).unwrap_or(v);
23    let parts: Vec<&str> = v.split('.').collect();
24    let major = parts
25        .first()
26        .and_then(|s| s.parse().ok())
27        .unwrap_or_else(|| {
28            tracing::warn!(version = v, "failed to parse major version component");
29            0
30        });
31    let minor = parts
32        .get(1)
33        .and_then(|s| s.parse().ok())
34        .unwrap_or_else(|| {
35            tracing::warn!(version = v, "failed to parse minor version component");
36            0
37        });
38    let patch = parts
39        .get(2)
40        .and_then(|s| s.parse().ok())
41        .unwrap_or_else(|| {
42            tracing::warn!(version = v, "failed to parse patch version component");
43            0
44        });
45    (major, minor, patch)
46}
47
48fn platform_archive_name(version: &str) -> Option<String> {
49    let (arch, os, ext) = platform_archive_parts()?;
50    Some(format!("roboticus-{version}-{arch}-{os}.{ext}"))
51}
52
53fn platform_archive_parts() -> Option<(&'static str, &'static str, &'static str)> {
54    let arch = match std::env::consts::ARCH {
55        "x86_64" => "x86_64",
56        "aarch64" => "aarch64",
57        _ => return None,
58    };
59    let os = match std::env::consts::OS {
60        "linux" => "linux",
61        "macos" => "darwin",
62        "windows" => "windows",
63        _ => return None,
64    };
65    let ext = if os == "windows" { "zip" } else { "tar.gz" };
66    Some((arch, os, ext))
67}
68
69fn parse_sha256sums_for_artifact(sha256sums: &str, artifact: &str) -> Option<String> {
70    for raw in sha256sums.lines() {
71        let line = raw.trim();
72        if line.is_empty() || line.starts_with('#') {
73            continue;
74        }
75        let mut parts = line.split_whitespace();
76        let hash = parts.next()?;
77        let file = parts.next()?;
78        if file == artifact {
79            return Some(hash.to_ascii_lowercase());
80        }
81    }
82    None
83}
84
85#[derive(Debug, Clone, Deserialize)]
86struct GitHubRelease {
87    tag_name: String,
88    draft: bool,
89    prerelease: bool,
90    published_at: Option<String>,
91    assets: Vec<GitHubAsset>,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95struct GitHubAsset {
96    name: String,
97}
98
99fn core_version(s: &str) -> &str {
100    let s = s.trim_start_matches('v');
101    let s = s.split_once('+').map(|(core, _)| core).unwrap_or(s);
102    s.split_once('-').map(|(core, _)| core).unwrap_or(s)
103}
104
105fn archive_suffixes(arch: &str, os: &str, ext: &str) -> Vec<String> {
106    let mut suffixes = vec![format!("-{arch}-{os}.{ext}")];
107    if os == "darwin" {
108        suffixes.push(format!("-{arch}-macos.{ext}"));
109    } else if os == "macos" {
110        suffixes.push(format!("-{arch}-darwin.{ext}"));
111    }
112    suffixes
113}
114
115fn select_archive_asset_name(release: &GitHubRelease, version: &str) -> Option<String> {
116    let (arch, os, ext) = platform_archive_parts()?;
117    let core_prefix = format!("roboticus-{}", core_version(version));
118
119    for suffix in archive_suffixes(arch, os, ext) {
120        let exact = format!("{core_prefix}{suffix}");
121        if release.assets.iter().any(|a| a.name == exact) {
122            return Some(exact);
123        }
124    }
125
126    let suffixes = archive_suffixes(arch, os, ext);
127    release.assets.iter().find_map(|a| {
128        if a.name.starts_with(&core_prefix) && suffixes.iter().any(|s| a.name.ends_with(s)) {
129            Some(a.name.clone())
130        } else {
131            None
132        }
133    })
134}
135
136fn release_supports_platform(release: &GitHubRelease, version: &str) -> bool {
137    release.assets.iter().any(|a| a.name == "SHA256SUMS.txt")
138        && select_archive_asset_name(release, version).is_some()
139}
140
141fn select_release_for_download(
142    releases: &[GitHubRelease],
143    version: &str,
144    current_version: &str,
145) -> Option<(String, String)> {
146    let canonical = format!("v{version}");
147
148    if let Some(exact) = releases
149        .iter()
150        .find(|r| !r.draft && !r.prerelease && r.tag_name == canonical)
151        && release_supports_platform(exact, version)
152        && let Some(archive) = select_archive_asset_name(exact, version)
153    {
154        return Some((exact.tag_name.clone(), archive));
155    }
156
157    if let Some(best_same_core) = releases
158        .iter()
159        .filter(|r| !r.draft && !r.prerelease)
160        .filter(|r| core_version(&r.tag_name) == core_version(version))
161        .filter(|r| release_supports_platform(r, version))
162        .filter_map(|r| select_archive_asset_name(r, version).map(|archive| (r, archive)))
163        .max_by_key(|(r, _)| r.published_at.as_deref().unwrap_or(""))
164        .map(|(r, archive)| (r.tag_name.clone(), archive))
165    {
166        return Some(best_same_core);
167    }
168
169    releases
170        .iter()
171        .filter(|r| !r.draft && !r.prerelease)
172        .filter(|r| is_newer(core_version(&r.tag_name), current_version))
173        .filter(|r| release_supports_platform(r, core_version(&r.tag_name)))
174        .filter_map(|r| {
175            let release_version = core_version(&r.tag_name);
176            select_archive_asset_name(r, release_version).map(|archive| (r, archive))
177        })
178        .max_by_key(|(r, _)| parse_semver(core_version(&r.tag_name)))
179        .map(|(r, archive)| (r.tag_name.clone(), archive))
180}
181
182async fn resolve_download_release(
183    client: &reqwest::Client,
184    version: &str,
185    current_version: &str,
186) -> Result<(String, String), Box<dyn std::error::Error>> {
187    let resp = client.get(GITHUB_RELEASES_API).send().await?;
188    if !resp.status().is_success() {
189        return Err(format!("Failed to query GitHub releases: HTTP {}", resp.status()).into());
190    }
191    let releases: Vec<GitHubRelease> = resp.json().await?;
192    select_release_for_download(&releases, version, current_version).ok_or_else(|| {
193        format!(
194            "No downloadable release found for v{version} with required platform archive and SHA256SUMS.txt"
195        )
196        .into()
197    })
198}
199
200fn find_file_recursive(root: &Path, filename: &str) -> io::Result<Option<PathBuf>> {
201    find_file_recursive_depth(root, filename, 10)
202}
203
204fn find_file_recursive_depth(
205    root: &Path,
206    filename: &str,
207    remaining_depth: usize,
208) -> io::Result<Option<PathBuf>> {
209    if remaining_depth == 0 {
210        return Ok(None);
211    }
212    for entry in std::fs::read_dir(root)? {
213        let entry = entry?;
214        let path = entry.path();
215        if path.is_dir() {
216            if let Some(found) = find_file_recursive_depth(&path, filename, remaining_depth - 1)? {
217                return Ok(Some(found));
218            }
219        } else if path
220            .file_name()
221            .and_then(|n| n.to_str())
222            .map(|n| n == filename)
223            .unwrap_or(false)
224        {
225            return Ok(Some(path));
226        }
227    }
228    Ok(None)
229}
230
231fn install_binary_bytes(bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
232    let exe = std::env::current_exe()?;
233
234    #[cfg(windows)]
235    {
236        // Windows cannot overwrite a running executable, but it CAN rename one.
237        // Strategy: rename running binary → .old, write new binary to original
238        // path, clean up .old on next launch (see `cleanup_old_binary()`).
239        let old_exe = exe.with_extension("exe.old");
240
241        // Remove any leftover .old from a prior update.
242        let _ = std::fs::remove_file(&old_exe);
243
244        // Rename the running binary out of the way.
245        std::fs::rename(&exe, &old_exe).map_err(|e| {
246            format!(
247                "failed to rename running binary to {}: {e} — \
248                 try closing all roboticus processes and retry",
249                old_exe.display()
250            )
251        })?;
252
253        // Write new binary to the original path (now free).
254        if let Err(e) = std::fs::write(&exe, bytes) {
255            // Rollback: move old binary back.
256            let _ = std::fs::rename(&old_exe, &exe);
257            return Err(format!("failed to write new binary: {e}").into());
258        }
259
260        tracing::info!(
261            old = %old_exe.display(),
262            new = %exe.display(),
263            "binary replaced via rename strategy; .old will be cleaned on next launch"
264        );
265        return Ok(());
266    }
267
268    #[cfg(not(windows))]
269    {
270        let tmp = exe.with_extension("new");
271        std::fs::write(&tmp, bytes)?;
272        #[cfg(unix)]
273        {
274            use std::os::unix::fs::PermissionsExt;
275            let mode = std::fs::metadata(&exe)
276                .map(|m| m.permissions().mode())
277                .unwrap_or(0o755);
278            std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
279        }
280        std::fs::rename(&tmp, &exe)?;
281        Ok(())
282    }
283}
284
285/// Remove stale `.exe.old` files left by the Windows rename-based update
286/// strategy. Safe to call on any platform — no-op on non-Windows.
287pub fn cleanup_old_binary() {
288    if let Ok(exe) = std::env::current_exe() {
289        let old = exe.with_extension("exe.old");
290        if old.exists() {
291            match std::fs::remove_file(&old) {
292                Ok(()) => tracing::debug!(path = %old.display(), "cleaned up old binary"),
293                Err(e) => {
294                    tracing::debug!(path = %old.display(), error = %e, "could not clean old binary (may still be in use)")
295                }
296            }
297        }
298    }
299}
300
301async fn apply_binary_download_update(
302    client: &reqwest::Client,
303    latest: &str,
304    current: &str,
305) -> Result<(), Box<dyn std::error::Error>> {
306    let _archive_probe = platform_archive_name(latest).ok_or_else(|| {
307        format!(
308            "No release archive mapping for platform {}/{}",
309            std::env::consts::OS,
310            std::env::consts::ARCH
311        )
312    })?;
313    let (tag, archive) = resolve_download_release(client, latest, current).await?;
314    let sha_url = format!("{RELEASE_BASE_URL}/{tag}/SHA256SUMS.txt");
315    let archive_url = format!("{RELEASE_BASE_URL}/{tag}/{archive}");
316
317    let sha_resp = client.get(&sha_url).send().await?;
318    if !sha_resp.status().is_success() {
319        return Err(format!("Failed to fetch SHA256SUMS.txt: HTTP {}", sha_resp.status()).into());
320    }
321    let sha_body = sha_resp.text().await?;
322    let expected = parse_sha256sums_for_artifact(&sha_body, &archive)
323        .ok_or_else(|| format!("No checksum found for artifact {archive}"))?;
324
325    let archive_resp = client.get(&archive_url).send().await?;
326    if !archive_resp.status().is_success() {
327        return Err(format!(
328            "Failed to download release archive: HTTP {}",
329            archive_resp.status()
330        )
331        .into());
332    }
333    let archive_bytes = archive_resp.bytes().await?.to_vec();
334    let actual = bytes_sha256(&archive_bytes);
335    if actual != expected {
336        return Err(
337            format!("SHA256 mismatch for {archive}: expected {expected}, got {actual}").into(),
338        );
339    }
340
341    let temp_root = std::env::temp_dir().join(format!(
342        "roboticus-update-{}-{}",
343        std::process::id(),
344        chrono::Utc::now().timestamp_millis()
345    ));
346    std::fs::create_dir_all(&temp_root)?;
347    let archive_path = if archive.ends_with(".zip") {
348        temp_root.join("roboticus.zip")
349    } else {
350        temp_root.join("roboticus.tar.gz")
351    };
352    std::fs::write(&archive_path, &archive_bytes)?;
353
354    if archive.ends_with(".zip") {
355        let status = std::process::Command::new("powershell")
356            .args([
357                "-NoProfile",
358                "-ExecutionPolicy",
359                "Bypass",
360                "-Command",
361                &format!(
362                    "Expand-Archive -Path \"{}\" -DestinationPath \"{}\" -Force",
363                    archive_path.display(),
364                    temp_root.display()
365                ),
366            ])
367            .status()?;
368        if !status.success() {
369            let _ = std::fs::remove_dir_all(&temp_root);
370            return Err(
371                format!("Failed to extract {archive} with PowerShell Expand-Archive").into(),
372            );
373        }
374    } else {
375        let status = std::process::Command::new("tar")
376            .arg("-xzf")
377            .arg(&archive_path)
378            .arg("-C")
379            .arg(&temp_root)
380            .status()?;
381        if !status.success() {
382            let _ = std::fs::remove_dir_all(&temp_root);
383            return Err(format!("Failed to extract {archive} with tar").into());
384        }
385    }
386
387    let bin_name = if std::env::consts::OS == "windows" {
388        "roboticus.exe"
389    } else {
390        "roboticus"
391    };
392    let extracted = find_file_recursive(&temp_root, bin_name)?
393        .ok_or_else(|| format!("Could not locate extracted {bin_name} binary"))?;
394    let bytes = std::fs::read(&extracted)?;
395    install_binary_bytes(&bytes)?;
396    let _ = std::fs::remove_dir_all(&temp_root);
397    Ok(())
398}
399
400fn c_compiler_available() -> bool {
401    #[cfg(windows)]
402    {
403        if std::process::Command::new("cmd")
404            .args(["/C", "where", "cl"])
405            .status()
406            .map(|s| s.success())
407            .unwrap_or(false)
408        {
409            return true;
410        }
411        if std::process::Command::new("gcc")
412            .arg("--version")
413            .status()
414            .map(|s| s.success())
415            .unwrap_or(false)
416        {
417            return true;
418        }
419        #[allow(clippy::needless_return)]
420        return std::process::Command::new("clang")
421            .arg("--version")
422            .status()
423            .map(|s| s.success())
424            .unwrap_or(false);
425    }
426
427    #[cfg(not(windows))]
428    {
429        if std::process::Command::new("cc")
430            .arg("--version")
431            .status()
432            .map(|s| s.success())
433            .unwrap_or(false)
434        {
435            return true;
436        }
437        if std::process::Command::new("clang")
438            .arg("--version")
439            .status()
440            .map(|s| s.success())
441            .unwrap_or(false)
442        {
443            return true;
444        }
445        std::process::Command::new("gcc")
446            .arg("--version")
447            .status()
448            .map(|s| s.success())
449            .unwrap_or(false)
450    }
451}
452
453/// Spawn `cargo install` as a detached process on Windows so the running
454/// executable's file lock is released before the build tries to overwrite it.
455#[cfg(windows)]
456fn apply_binary_cargo_update_detached(latest: &str) -> bool {
457    let (_, _, _, _, _, _, _, _, _) = colors();
458    let (OK, _, WARN, DETAIL, ERR) = icons();
459
460    if !c_compiler_available() {
461        println!("    {WARN} Local build toolchain check failed: no C compiler found in PATH");
462        println!(
463            "    {DETAIL} `--method build` requires a C compiler (and related native build tools)."
464        );
465        println!("    {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc.");
466        return false;
467    }
468
469    let staging_dir = std::env::temp_dir().join(format!(
470        "roboticus-build-{}-{}",
471        std::process::id(),
472        chrono::Utc::now().timestamp_millis()
473    ));
474    if std::fs::create_dir_all(&staging_dir).is_err() {
475        println!("    {ERR} Could not create staging directory");
476        return false;
477    }
478
479    let log_file = staging_dir.join("cargo-build-update.log");
480    let script_path = staging_dir.join("cargo-build-update.cmd");
481
482    let cargo_exe = which_cargo().unwrap_or_else(|| "cargo".to_string());
483
484    let script = format!(
485        "@echo off\r\n\
486         setlocal\r\n\
487         set LOG={log}\r\n\
488         echo [%DATE% %TIME%] Waiting for roboticus process to exit... >> \"%LOG%\"\r\n\
489         :wait\r\n\
490         tasklist /FI \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul && (\r\n\
491           timeout /t 1 /nobreak >nul\r\n\
492           goto :wait\r\n\
493         )\r\n\
494         echo [%DATE% %TIME%] Process exited, starting cargo install... >> \"%LOG%\"\r\n\
495         \"{cargo}\" install {crate_name} --version {version} --force >> \"%LOG%\" 2>&1\r\n\
496         if errorlevel 1 (\r\n\
497           echo [%DATE% %TIME%] FAILED: cargo install exited with error >> \"%LOG%\"\r\n\
498           echo.\r\n\
499           echo Roboticus build update FAILED. See log: %LOG%\r\n\
500           pause\r\n\
501           exit /b 1\r\n\
502         )\r\n\
503         echo [%DATE% %TIME%] SUCCESS: binary updated to v{version} >> \"%LOG%\"\r\n\
504         echo.\r\n\
505         echo Roboticus updated to v{version} successfully.\r\n\
506         timeout /t 5 /nobreak >nul\r\n\
507         exit /b 0\r\n",
508        log = log_file.display(),
509        pid = std::process::id(),
510        cargo = cargo_exe,
511        crate_name = CRATE_NAME,
512        version = latest,
513    );
514
515    if std::fs::write(&script_path, &script).is_err() {
516        println!("    {ERR} Could not write build script");
517        return false;
518    }
519
520    match std::process::Command::new("cmd")
521        .args(["/C", "start", "\"Roboticus Update\"", "/MIN"])
522        .arg(script_path.to_string_lossy().as_ref())
523        .creation_flags(0x00000008) // DETACHED_PROCESS
524        .spawn()
525    {
526        Ok(_) => {
527            println!("    {OK} Build update spawned in background");
528            println!("    {DETAIL} This process will exit so the file lock is released.");
529            println!(
530                "    {DETAIL} A console window will show build progress. Log: {}",
531                log_file.display()
532            );
533            println!(
534                "    {DETAIL} Re-run `roboticus version` after the build completes to confirm."
535            );
536            true
537        }
538        Err(e) => {
539            println!("    {ERR} Failed to spawn detached build: {e}");
540            println!(
541                "    {DETAIL} Run `cargo install {CRATE_NAME} --force` manually from a separate shell."
542            );
543            false
544        }
545    }
546}
547
548#[cfg(windows)]
549fn which_cargo() -> Option<String> {
550    std::process::Command::new("cmd")
551        .args(["/C", "where", "cargo"])
552        .output()
553        .ok()
554        .and_then(|o| {
555            if o.status.success() {
556                String::from_utf8(o.stdout)
557                    .ok()
558                    .and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
559            } else {
560                None
561            }
562        })
563}
564
565fn apply_binary_cargo_update(latest: &str) -> bool {
566    let (DIM, _, _, _, _, _, _, RESET, _) = colors();
567    let (OK, _, WARN, DETAIL, ERR) = icons();
568    if !c_compiler_available() {
569        println!("    {WARN} Local build toolchain check failed: no C compiler found in PATH");
570        println!(
571            "    {DETAIL} `--method build` requires a C compiler (and related native build tools)."
572        );
573        println!(
574            "    {DETAIL} Recommended: use `roboticus update binary --method download --yes`."
575        );
576        #[cfg(windows)]
577        {
578            println!(
579                "    {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc."
580            );
581        }
582        #[cfg(target_os = "macos")]
583        {
584            println!("    {DETAIL} macOS: run `xcode-select --install`.");
585        }
586        #[cfg(target_os = "linux")]
587        {
588            println!(
589                "    {DETAIL} Linux: install build tools (for example `build-essential` on Debian/Ubuntu)."
590            );
591        }
592        return false;
593    }
594    println!("    Installing v{latest} via cargo install...");
595    println!("    {DIM}This may take a few minutes.{RESET}");
596
597    let status = std::process::Command::new("cargo")
598        .args(["install", CRATE_NAME])
599        .status();
600
601    match status {
602        Ok(s) if s.success() => {
603            println!("    {OK} Binary updated to v{latest}");
604            true
605        }
606        Ok(s) => {
607            println!(
608                "    {ERR} cargo install exited with code {}",
609                s.code().unwrap_or(-1)
610            );
611            false
612        }
613        Err(e) => {
614            println!("    {ERR} Failed to run cargo install: {e}");
615            println!("    {DIM}Ensure cargo is in your PATH{RESET}");
616            false
617        }
618    }
619}
620
621// ── Binary update ────────────────────────────────────────────
622
623pub(crate) async fn check_binary_version(
624    client: &reqwest::Client,
625) -> Result<Option<String>, Box<dyn std::error::Error>> {
626    let resp = client.get(CRATES_IO_API).send().await?;
627    if !resp.status().is_success() {
628        return Ok(None);
629    }
630    let body: serde_json::Value = resp.json().await?;
631    let latest = body
632        .pointer("/crate/max_version")
633        .and_then(|v| v.as_str())
634        .map(String::from);
635    Ok(latest)
636}
637
638pub(super) async fn apply_binary_update(
639    yes: bool,
640    method: &str,
641    force: bool,
642) -> Result<bool, Box<dyn std::error::Error>> {
643    let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
644    let (OK, _, WARN, DETAIL, ERR) = icons();
645    let current = env!("CARGO_PKG_VERSION");
646    let client = super::http_client()?;
647    let method = method.to_ascii_lowercase();
648
649    println!("\n  {BOLD}Binary Update{RESET}\n");
650    println!("    Current version: {MONO}v{current}{RESET}");
651
652    let latest = match check_binary_version(&client).await? {
653        Some(v) => v,
654        None => {
655            println!("    {WARN} Could not reach crates.io");
656            return Ok(false);
657        }
658    };
659
660    println!("    Latest version:  {MONO}v{latest}{RESET}");
661
662    if force {
663        println!("    --force: skipping version check, forcing reinstall");
664    } else if !is_newer(&latest, current) {
665        println!("    {OK} Already on latest version");
666        return Ok(false);
667    }
668
669    println!("    {GREEN}New version available: v{latest}{RESET}");
670    println!();
671
672    if std::env::consts::OS == "windows" && method == "build" {
673        if !yes
674            && !confirm_action(
675                "Build on Windows requires a detached process (this session will exit). Proceed?",
676                true,
677            )
678        {
679            println!("    Skipped.");
680            return Ok(false);
681        }
682        #[cfg(windows)]
683        {
684            return Ok(apply_binary_cargo_update_detached(&latest));
685        }
686        #[cfg(not(windows))]
687        {
688            return Ok(false);
689        }
690    }
691
692    if !yes && !confirm_action("Proceed with binary update?", true) {
693        println!("    Skipped.");
694        return Ok(false);
695    }
696
697    let mut updated = false;
698    if method == "download" {
699        println!("    Attempting platform binary download + fingerprint verification...");
700        match apply_binary_download_update(&client, &latest, current).await {
701            Ok(()) => {
702                println!("    {OK} Binary downloaded and verified (SHA256)");
703                if std::env::consts::OS == "windows" {
704                    println!(
705                        "    {DETAIL} Update staged. The replacement finalizes after this process exits."
706                    );
707                    println!(
708                        "    {DETAIL} Re-run `roboticus version` in a few seconds to confirm."
709                    );
710                }
711                updated = true;
712            }
713            Err(e) => {
714                println!("    {WARN} Download update failed: {e}");
715                if std::env::consts::OS == "windows" {
716                    if confirm_action(
717                        "Download failed. Fall back to cargo build? (spawns detached process, this session exits)",
718                        true,
719                    ) {
720                        #[cfg(windows)]
721                        {
722                            updated = apply_binary_cargo_update_detached(&latest);
723                        }
724                    } else {
725                        println!("    Skipped fallback build.");
726                    }
727                } else if confirm_action(
728                    "Download failed. Fall back to cargo build update? (slower, compiles from source)",
729                    true,
730                ) {
731                    updated = apply_binary_cargo_update(&latest);
732                } else {
733                    println!("    Skipped fallback build.");
734                }
735            }
736        }
737    } else {
738        updated = apply_binary_cargo_update(&latest);
739    }
740
741    if updated {
742        println!("    {OK} Binary updated to v{latest}");
743        let mut state = UpdateState::load();
744        state.binary_version = latest;
745        state.last_check = now_iso();
746        state
747            .save()
748            .inspect_err(
749                |e| tracing::warn!(error = %e, "failed to save update state after version check"),
750            )
751            .ok();
752        Ok(true)
753    } else {
754        if method == "download" {
755            println!("    {ERR} Binary update did not complete");
756        }
757        Ok(false)
758    }
759}
760
761// ── CLI entry points ─────────────────────────────────────────
762
763pub async fn cmd_update_binary(
764    _channel: &str,
765    yes: bool,
766    method: &str,
767    hygiene_fn: Option<&super::HygieneFn>,
768) -> Result<(), Box<dyn std::error::Error>> {
769    heading("Roboticus Binary Update");
770    apply_binary_update(yes, method, false).await?;
771    super::run_oauth_storage_maintenance();
772    let config_path = roboticus_core::config::resolve_config_path(None).unwrap_or_else(|| {
773        roboticus_core::home_dir()
774            .join(".roboticus")
775            .join("roboticus.toml")
776    });
777    super::run_mechanic_checks_maintenance(&config_path.to_string_lossy(), hygiene_fn);
778    println!();
779    Ok(())
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    #[test]
787    fn semver_parse_basic() {
788        assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
789        assert_eq!(parse_semver("v0.1.0"), (0, 1, 0));
790        assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
791    }
792
793    #[test]
794    fn is_newer_works() {
795        assert!(is_newer("0.2.0", "0.1.0"));
796        assert!(is_newer("1.0.0", "0.9.9"));
797        assert!(!is_newer("0.1.0", "0.1.0"));
798        assert!(!is_newer("0.1.0", "0.2.0"));
799    }
800
801    #[test]
802    fn is_newer_patch_bump() {
803        assert!(is_newer("1.0.1", "1.0.0"));
804        assert!(!is_newer("1.0.0", "1.0.1"));
805    }
806
807    #[test]
808    fn is_newer_same_version() {
809        assert!(!is_newer("1.0.0", "1.0.0"));
810    }
811
812    #[test]
813    fn platform_archive_name_supported() {
814        let name = platform_archive_name("1.2.3");
815        if let Some(n) = name {
816            assert!(n.contains("roboticus-1.2.3-"));
817        }
818    }
819
820    #[test]
821    fn parse_semver_partial_version() {
822        assert_eq!(parse_semver("1"), (1, 0, 0));
823        assert_eq!(parse_semver("1.2"), (1, 2, 0));
824    }
825
826    #[test]
827    fn parse_semver_empty() {
828        assert_eq!(parse_semver(""), (0, 0, 0));
829    }
830
831    #[test]
832    fn parse_semver_with_v_prefix() {
833        assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
834    }
835
836    #[test]
837    fn parse_semver_ignores_build_and_prerelease_metadata() {
838        assert_eq!(parse_semver("0.9.4+hotfix.1"), (0, 9, 4));
839        assert_eq!(parse_semver("v1.2.3-rc.1"), (1, 2, 3));
840    }
841
842    #[test]
843    fn parse_sha256sums_for_artifact_finds_exact_entry() {
844        let sums = "\
845abc123  roboticus-0.8.0-darwin-aarch64.tar.gz\n\
846def456  roboticus-0.8.0-linux-x86_64.tar.gz\n";
847        let hash = parse_sha256sums_for_artifact(sums, "roboticus-0.8.0-linux-x86_64.tar.gz");
848        assert_eq!(hash.as_deref(), Some("def456"));
849    }
850
851    #[test]
852    fn find_file_recursive_finds_nested_target() {
853        let dir = tempfile::tempdir().unwrap();
854        let nested = dir.path().join("a").join("b");
855        std::fs::create_dir_all(&nested).unwrap();
856        let target = nested.join("needle.txt");
857        std::fs::write(&target, "x").unwrap();
858        let found = find_file_recursive(dir.path(), "needle.txt").unwrap();
859        assert_eq!(found.as_deref(), Some(target.as_path()));
860    }
861
862    #[test]
863    fn parse_sha256sums_for_artifact_returns_none_when_missing() {
864        let sums = "abc123  file-a.tar.gz\n";
865        assert!(parse_sha256sums_for_artifact(sums, "file-b.tar.gz").is_none());
866    }
867
868    #[test]
869    fn select_release_for_download_prefers_exact_tag() {
870        let archive = platform_archive_name("0.9.4").unwrap();
871        let releases = vec![
872            GitHubRelease {
873                tag_name: "v0.9.4+hotfix.1".into(),
874                draft: false,
875                prerelease: false,
876                published_at: Some("2026-03-05T11:36:51Z".into()),
877                assets: vec![
878                    GitHubAsset {
879                        name: "SHA256SUMS.txt".into(),
880                    },
881                    GitHubAsset {
882                        name: format!(
883                            "roboticus-0.9.4+hotfix.1-{}",
884                            &archive["roboticus-0.9.4-".len()..]
885                        ),
886                    },
887                ],
888            },
889            GitHubRelease {
890                tag_name: "v0.9.4".into(),
891                draft: false,
892                prerelease: false,
893                published_at: Some("2026-03-05T10:00:00Z".into()),
894                assets: vec![
895                    GitHubAsset {
896                        name: "SHA256SUMS.txt".into(),
897                    },
898                    GitHubAsset {
899                        name: archive.clone(),
900                    },
901                ],
902            },
903        ];
904
905        let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
906        assert_eq!(
907            selected.as_ref().map(|(tag, _)| tag.as_str()),
908            Some("v0.9.4")
909        );
910    }
911
912    #[test]
913    fn select_release_for_download_falls_back_to_hotfix_tag() {
914        let archive = platform_archive_name("0.9.4").unwrap();
915        let suffix = &archive["roboticus-0.9.4-".len()..];
916        let releases = vec![
917            GitHubRelease {
918                tag_name: "v0.9.4".into(),
919                draft: false,
920                prerelease: false,
921                published_at: Some("2026-03-05T10:00:00Z".into()),
922                assets: vec![GitHubAsset {
923                    name: "PROVENANCE.json".into(),
924                }],
925            },
926            GitHubRelease {
927                tag_name: "v0.9.4+hotfix.2".into(),
928                draft: false,
929                prerelease: false,
930                published_at: Some("2026-03-05T12:00:00Z".into()),
931                assets: vec![
932                    GitHubAsset {
933                        name: "SHA256SUMS.txt".into(),
934                    },
935                    GitHubAsset {
936                        name: format!("roboticus-0.9.4+hotfix.2-{suffix}"),
937                    },
938                ],
939            },
940        ];
941
942        let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
943        let expected_archive = format!("roboticus-0.9.4+hotfix.2-{suffix}");
944        assert_eq!(
945            selected.as_ref().map(|(tag, _)| tag.as_str()),
946            Some("v0.9.4+hotfix.2")
947        );
948        assert_eq!(
949            selected
950                .as_ref()
951                .map(|(_, archive_name)| archive_name.as_str()),
952            Some(expected_archive.as_str())
953        );
954    }
955
956    #[test]
957    fn select_release_for_download_falls_back_to_latest_compatible_version() {
958        let archive_010 = platform_archive_name("0.10.0").unwrap();
959        let archive_099 = platform_archive_name("0.9.9").unwrap();
960        let releases = vec![
961            GitHubRelease {
962                tag_name: "v0.10.0".into(),
963                draft: false,
964                prerelease: false,
965                published_at: Some("2026-03-23T12:00:00Z".into()),
966                assets: vec![GitHubAsset {
967                    name: "SHA256SUMS.txt".into(),
968                }],
969            },
970            GitHubRelease {
971                tag_name: "v0.9.9".into(),
972                draft: false,
973                prerelease: false,
974                published_at: Some("2026-03-20T12:00:00Z".into()),
975                assets: vec![
976                    GitHubAsset {
977                        name: "SHA256SUMS.txt".into(),
978                    },
979                    GitHubAsset { name: archive_099 },
980                ],
981            },
982            GitHubRelease {
983                tag_name: "v0.9.8".into(),
984                draft: false,
985                prerelease: false,
986                published_at: Some("2026-03-17T12:00:00Z".into()),
987                assets: vec![
988                    GitHubAsset {
989                        name: "SHA256SUMS.txt".into(),
990                    },
991                    GitHubAsset { name: archive_010 },
992                ],
993            },
994        ];
995
996        let selected = select_release_for_download(&releases, "0.10.0", "0.9.7");
997        assert_eq!(
998            selected.as_ref().map(|(tag, _)| tag.as_str()),
999            Some("v0.9.9")
1000        );
1001    }
1002
1003    #[test]
1004    fn archive_suffixes_include_macos_alias_for_darwin() {
1005        let suffixes = archive_suffixes("aarch64", "darwin", "tar.gz");
1006        assert!(suffixes.contains(&"-aarch64-darwin.tar.gz".to_string()));
1007        assert!(suffixes.contains(&"-aarch64-macos.tar.gz".to_string()));
1008    }
1009
1010    #[test]
1011    fn find_file_recursive_returns_none_when_not_found() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let found = find_file_recursive(dir.path(), "does-not-exist.txt").unwrap();
1014        assert!(found.is_none());
1015    }
1016}