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    #[cfg(windows)]
233    {
234        let exe = std::env::current_exe()?;
235        let staging_dir = std::env::temp_dir().join(format!(
236            "roboticus-update-{}-{}",
237            std::process::id(),
238            chrono::Utc::now().timestamp_millis()
239        ));
240        std::fs::create_dir_all(&staging_dir)?;
241        let staged_exe = staging_dir.join("roboticus-staged.exe");
242        std::fs::write(&staged_exe, bytes)?;
243        let log_file = staging_dir.join("apply-update.log");
244        let script_path = staging_dir.join("apply-update.cmd");
245        let script = format!(
246            "@echo off\r\n\
247             setlocal\r\n\
248             set SRC={src}\r\n\
249             set DST={dst}\r\n\
250             set LOG={log}\r\n\
251             echo [%DATE% %TIME%] Starting binary replacement >> \"%LOG%\"\r\n\
252             for /L %%i in (1,1,60) do (\r\n\
253               copy /Y \"%SRC%\" \"%DST%\" >nul 2>nul && goto :ok\r\n\
254               timeout /t 1 /nobreak >nul\r\n\
255             )\r\n\
256             echo [%DATE% %TIME%] FAILED: could not replace binary after 60 attempts >> \"%LOG%\"\r\n\
257             exit /b 1\r\n\
258             :ok\r\n\
259             echo [%DATE% %TIME%] SUCCESS: binary replaced >> \"%LOG%\"\r\n\
260             del /Q \"%SRC%\" >nul 2>nul\r\n\
261             del /Q \"%~f0\" >nul 2>nul\r\n\
262             exit /b 0\r\n",
263            src = staged_exe.display(),
264            dst = exe.display(),
265            log = log_file.display(),
266        );
267        std::fs::write(&script_path, &script)?;
268        let _child = std::process::Command::new("cmd")
269            .arg("/C")
270            .arg(script_path.to_string_lossy().as_ref())
271            .creation_flags(0x00000008) // DETACHED_PROCESS
272            .spawn()?;
273        #[allow(clippy::needless_return)]
274        return Ok(());
275    }
276
277    #[cfg(not(windows))]
278    {
279        let exe = std::env::current_exe()?;
280        let tmp = exe.with_extension("new");
281        std::fs::write(&tmp, bytes)?;
282        #[cfg(unix)]
283        {
284            use std::os::unix::fs::PermissionsExt;
285            let mode = std::fs::metadata(&exe)
286                .map(|m| m.permissions().mode())
287                .unwrap_or(0o755);
288            std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode))?;
289        }
290        std::fs::rename(&tmp, &exe)?;
291        Ok(())
292    }
293}
294
295async fn apply_binary_download_update(
296    client: &reqwest::Client,
297    latest: &str,
298    current: &str,
299) -> Result<(), Box<dyn std::error::Error>> {
300    let _archive_probe = platform_archive_name(latest).ok_or_else(|| {
301        format!(
302            "No release archive mapping for platform {}/{}",
303            std::env::consts::OS,
304            std::env::consts::ARCH
305        )
306    })?;
307    let (tag, archive) = resolve_download_release(client, latest, current).await?;
308    let sha_url = format!("{RELEASE_BASE_URL}/{tag}/SHA256SUMS.txt");
309    let archive_url = format!("{RELEASE_BASE_URL}/{tag}/{archive}");
310
311    let sha_resp = client.get(&sha_url).send().await?;
312    if !sha_resp.status().is_success() {
313        return Err(format!("Failed to fetch SHA256SUMS.txt: HTTP {}", sha_resp.status()).into());
314    }
315    let sha_body = sha_resp.text().await?;
316    let expected = parse_sha256sums_for_artifact(&sha_body, &archive)
317        .ok_or_else(|| format!("No checksum found for artifact {archive}"))?;
318
319    let archive_resp = client.get(&archive_url).send().await?;
320    if !archive_resp.status().is_success() {
321        return Err(format!(
322            "Failed to download release archive: HTTP {}",
323            archive_resp.status()
324        )
325        .into());
326    }
327    let archive_bytes = archive_resp.bytes().await?.to_vec();
328    let actual = bytes_sha256(&archive_bytes);
329    if actual != expected {
330        return Err(
331            format!("SHA256 mismatch for {archive}: expected {expected}, got {actual}").into(),
332        );
333    }
334
335    let temp_root = std::env::temp_dir().join(format!(
336        "roboticus-update-{}-{}",
337        std::process::id(),
338        chrono::Utc::now().timestamp_millis()
339    ));
340    std::fs::create_dir_all(&temp_root)?;
341    let archive_path = if archive.ends_with(".zip") {
342        temp_root.join("roboticus.zip")
343    } else {
344        temp_root.join("roboticus.tar.gz")
345    };
346    std::fs::write(&archive_path, &archive_bytes)?;
347
348    if archive.ends_with(".zip") {
349        let status = std::process::Command::new("powershell")
350            .args([
351                "-NoProfile",
352                "-ExecutionPolicy",
353                "Bypass",
354                "-Command",
355                &format!(
356                    "Expand-Archive -Path \"{}\" -DestinationPath \"{}\" -Force",
357                    archive_path.display(),
358                    temp_root.display()
359                ),
360            ])
361            .status()?;
362        if !status.success() {
363            let _ = std::fs::remove_dir_all(&temp_root);
364            return Err(
365                format!("Failed to extract {archive} with PowerShell Expand-Archive").into(),
366            );
367        }
368    } else {
369        let status = std::process::Command::new("tar")
370            .arg("-xzf")
371            .arg(&archive_path)
372            .arg("-C")
373            .arg(&temp_root)
374            .status()?;
375        if !status.success() {
376            let _ = std::fs::remove_dir_all(&temp_root);
377            return Err(format!("Failed to extract {archive} with tar").into());
378        }
379    }
380
381    let bin_name = if std::env::consts::OS == "windows" {
382        "roboticus.exe"
383    } else {
384        "roboticus"
385    };
386    let extracted = find_file_recursive(&temp_root, bin_name)?
387        .ok_or_else(|| format!("Could not locate extracted {bin_name} binary"))?;
388    let bytes = std::fs::read(&extracted)?;
389    install_binary_bytes(&bytes)?;
390    let _ = std::fs::remove_dir_all(&temp_root);
391    Ok(())
392}
393
394fn c_compiler_available() -> bool {
395    #[cfg(windows)]
396    {
397        if std::process::Command::new("cmd")
398            .args(["/C", "where", "cl"])
399            .status()
400            .map(|s| s.success())
401            .unwrap_or(false)
402        {
403            return true;
404        }
405        if std::process::Command::new("gcc")
406            .arg("--version")
407            .status()
408            .map(|s| s.success())
409            .unwrap_or(false)
410        {
411            return true;
412        }
413        #[allow(clippy::needless_return)]
414        return std::process::Command::new("clang")
415            .arg("--version")
416            .status()
417            .map(|s| s.success())
418            .unwrap_or(false);
419    }
420
421    #[cfg(not(windows))]
422    {
423        if std::process::Command::new("cc")
424            .arg("--version")
425            .status()
426            .map(|s| s.success())
427            .unwrap_or(false)
428        {
429            return true;
430        }
431        if std::process::Command::new("clang")
432            .arg("--version")
433            .status()
434            .map(|s| s.success())
435            .unwrap_or(false)
436        {
437            return true;
438        }
439        std::process::Command::new("gcc")
440            .arg("--version")
441            .status()
442            .map(|s| s.success())
443            .unwrap_or(false)
444    }
445}
446
447/// Spawn `cargo install` as a detached process on Windows so the running
448/// executable's file lock is released before the build tries to overwrite it.
449#[cfg(windows)]
450fn apply_binary_cargo_update_detached(latest: &str) -> bool {
451    let (_, _, _, _, _, _, _, _, _) = colors();
452    let (OK, _, WARN, DETAIL, ERR) = icons();
453
454    if !c_compiler_available() {
455        println!("    {WARN} Local build toolchain check failed: no C compiler found in PATH");
456        println!(
457            "    {DETAIL} `--method build` requires a C compiler (and related native build tools)."
458        );
459        println!("    {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc.");
460        return false;
461    }
462
463    let staging_dir = std::env::temp_dir().join(format!(
464        "roboticus-build-{}-{}",
465        std::process::id(),
466        chrono::Utc::now().timestamp_millis()
467    ));
468    if std::fs::create_dir_all(&staging_dir).is_err() {
469        println!("    {ERR} Could not create staging directory");
470        return false;
471    }
472
473    let log_file = staging_dir.join("cargo-build-update.log");
474    let script_path = staging_dir.join("cargo-build-update.cmd");
475
476    let cargo_exe = which_cargo().unwrap_or_else(|| "cargo".to_string());
477
478    let script = format!(
479        "@echo off\r\n\
480         setlocal\r\n\
481         set LOG={log}\r\n\
482         echo [%DATE% %TIME%] Waiting for roboticus process to exit... >> \"%LOG%\"\r\n\
483         :wait\r\n\
484         tasklist /FI \"PID eq {pid}\" 2>nul | find \"{pid}\" >nul && (\r\n\
485           timeout /t 1 /nobreak >nul\r\n\
486           goto :wait\r\n\
487         )\r\n\
488         echo [%DATE% %TIME%] Process exited, starting cargo install... >> \"%LOG%\"\r\n\
489         \"{cargo}\" install {crate_name} --version {version} --force >> \"%LOG%\" 2>&1\r\n\
490         if errorlevel 1 (\r\n\
491           echo [%DATE% %TIME%] FAILED: cargo install exited with error >> \"%LOG%\"\r\n\
492           echo.\r\n\
493           echo Roboticus build update FAILED. See log: %LOG%\r\n\
494           pause\r\n\
495           exit /b 1\r\n\
496         )\r\n\
497         echo [%DATE% %TIME%] SUCCESS: binary updated to v{version} >> \"%LOG%\"\r\n\
498         echo.\r\n\
499         echo Roboticus updated to v{version} successfully.\r\n\
500         timeout /t 5 /nobreak >nul\r\n\
501         exit /b 0\r\n",
502        log = log_file.display(),
503        pid = std::process::id(),
504        cargo = cargo_exe,
505        crate_name = CRATE_NAME,
506        version = latest,
507    );
508
509    if std::fs::write(&script_path, &script).is_err() {
510        println!("    {ERR} Could not write build script");
511        return false;
512    }
513
514    match std::process::Command::new("cmd")
515        .args(["/C", "start", "\"Roboticus Update\"", "/MIN"])
516        .arg(script_path.to_string_lossy().as_ref())
517        .creation_flags(0x00000008) // DETACHED_PROCESS
518        .spawn()
519    {
520        Ok(_) => {
521            println!("    {OK} Build update spawned in background");
522            println!("    {DETAIL} This process will exit so the file lock is released.");
523            println!(
524                "    {DETAIL} A console window will show build progress. Log: {}",
525                log_file.display()
526            );
527            println!(
528                "    {DETAIL} Re-run `roboticus version` after the build completes to confirm."
529            );
530            true
531        }
532        Err(e) => {
533            println!("    {ERR} Failed to spawn detached build: {e}");
534            println!(
535                "    {DETAIL} Run `cargo install {CRATE_NAME} --force` manually from a separate shell."
536            );
537            false
538        }
539    }
540}
541
542#[cfg(windows)]
543fn which_cargo() -> Option<String> {
544    std::process::Command::new("cmd")
545        .args(["/C", "where", "cargo"])
546        .output()
547        .ok()
548        .and_then(|o| {
549            if o.status.success() {
550                String::from_utf8(o.stdout)
551                    .ok()
552                    .and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
553            } else {
554                None
555            }
556        })
557}
558
559fn apply_binary_cargo_update(latest: &str) -> bool {
560    let (DIM, _, _, _, _, _, _, RESET, _) = colors();
561    let (OK, _, WARN, DETAIL, ERR) = icons();
562    if !c_compiler_available() {
563        println!("    {WARN} Local build toolchain check failed: no C compiler found in PATH");
564        println!(
565            "    {DETAIL} `--method build` requires a C compiler (and related native build tools)."
566        );
567        println!(
568            "    {DETAIL} Recommended: use `roboticus update binary --method download --yes`."
569        );
570        #[cfg(windows)]
571        {
572            println!(
573                "    {DETAIL} Windows: install Visual Studio Build Tools (MSVC) or clang/gcc."
574            );
575        }
576        #[cfg(target_os = "macos")]
577        {
578            println!("    {DETAIL} macOS: run `xcode-select --install`.");
579        }
580        #[cfg(target_os = "linux")]
581        {
582            println!(
583                "    {DETAIL} Linux: install build tools (for example `build-essential` on Debian/Ubuntu)."
584            );
585        }
586        return false;
587    }
588    println!("    Installing v{latest} via cargo install...");
589    println!("    {DIM}This may take a few minutes.{RESET}");
590
591    let status = std::process::Command::new("cargo")
592        .args(["install", CRATE_NAME])
593        .status();
594
595    match status {
596        Ok(s) if s.success() => {
597            println!("    {OK} Binary updated to v{latest}");
598            true
599        }
600        Ok(s) => {
601            println!(
602                "    {ERR} cargo install exited with code {}",
603                s.code().unwrap_or(-1)
604            );
605            false
606        }
607        Err(e) => {
608            println!("    {ERR} Failed to run cargo install: {e}");
609            println!("    {DIM}Ensure cargo is in your PATH{RESET}");
610            false
611        }
612    }
613}
614
615// ── Binary update ────────────────────────────────────────────
616
617pub(crate) async fn check_binary_version(
618    client: &reqwest::Client,
619) -> Result<Option<String>, Box<dyn std::error::Error>> {
620    let resp = client.get(CRATES_IO_API).send().await?;
621    if !resp.status().is_success() {
622        return Ok(None);
623    }
624    let body: serde_json::Value = resp.json().await?;
625    let latest = body
626        .pointer("/crate/max_version")
627        .and_then(|v| v.as_str())
628        .map(String::from);
629    Ok(latest)
630}
631
632pub(super) async fn apply_binary_update(
633    yes: bool,
634    method: &str,
635    force: bool,
636) -> Result<bool, Box<dyn std::error::Error>> {
637    let (DIM, BOLD, _, GREEN, _, _, _, RESET, MONO) = colors();
638    let (OK, _, WARN, DETAIL, ERR) = icons();
639    let current = env!("CARGO_PKG_VERSION");
640    let client = super::http_client()?;
641    let method = method.to_ascii_lowercase();
642
643    println!("\n  {BOLD}Binary Update{RESET}\n");
644    println!("    Current version: {MONO}v{current}{RESET}");
645
646    let latest = match check_binary_version(&client).await? {
647        Some(v) => v,
648        None => {
649            println!("    {WARN} Could not reach crates.io");
650            return Ok(false);
651        }
652    };
653
654    println!("    Latest version:  {MONO}v{latest}{RESET}");
655
656    if force {
657        println!("    --force: skipping version check, forcing reinstall");
658    } else if !is_newer(&latest, current) {
659        println!("    {OK} Already on latest version");
660        return Ok(false);
661    }
662
663    println!("    {GREEN}New version available: v{latest}{RESET}");
664    println!();
665
666    if std::env::consts::OS == "windows" && method == "build" {
667        if !yes
668            && !confirm_action(
669                "Build on Windows requires a detached process (this session will exit). Proceed?",
670                true,
671            )
672        {
673            println!("    Skipped.");
674            return Ok(false);
675        }
676        #[cfg(windows)]
677        {
678            return Ok(apply_binary_cargo_update_detached(&latest));
679        }
680        #[cfg(not(windows))]
681        {
682            return Ok(false);
683        }
684    }
685
686    if !yes && !confirm_action("Proceed with binary update?", true) {
687        println!("    Skipped.");
688        return Ok(false);
689    }
690
691    let mut updated = false;
692    if method == "download" {
693        println!("    Attempting platform binary download + fingerprint verification...");
694        match apply_binary_download_update(&client, &latest, current).await {
695            Ok(()) => {
696                println!("    {OK} Binary downloaded and verified (SHA256)");
697                if std::env::consts::OS == "windows" {
698                    println!(
699                        "    {DETAIL} Update staged. The replacement finalizes after this process exits."
700                    );
701                    println!(
702                        "    {DETAIL} Re-run `roboticus version` in a few seconds to confirm."
703                    );
704                }
705                updated = true;
706            }
707            Err(e) => {
708                println!("    {WARN} Download update failed: {e}");
709                if std::env::consts::OS == "windows" {
710                    if confirm_action(
711                        "Download failed. Fall back to cargo build? (spawns detached process, this session exits)",
712                        true,
713                    ) {
714                        #[cfg(windows)]
715                        {
716                            updated = apply_binary_cargo_update_detached(&latest);
717                        }
718                    } else {
719                        println!("    Skipped fallback build.");
720                    }
721                } else if confirm_action(
722                    "Download failed. Fall back to cargo build update? (slower, compiles from source)",
723                    true,
724                ) {
725                    updated = apply_binary_cargo_update(&latest);
726                } else {
727                    println!("    Skipped fallback build.");
728                }
729            }
730        }
731    } else {
732        updated = apply_binary_cargo_update(&latest);
733    }
734
735    if updated {
736        println!("    {OK} Binary updated to v{latest}");
737        let mut state = UpdateState::load();
738        state.binary_version = latest;
739        state.last_check = now_iso();
740        state
741            .save()
742            .inspect_err(
743                |e| tracing::warn!(error = %e, "failed to save update state after version check"),
744            )
745            .ok();
746        Ok(true)
747    } else {
748        if method == "download" {
749            println!("    {ERR} Binary update did not complete");
750        }
751        Ok(false)
752    }
753}
754
755// ── CLI entry points ─────────────────────────────────────────
756
757pub async fn cmd_update_binary(
758    _channel: &str,
759    yes: bool,
760    method: &str,
761    hygiene_fn: Option<&super::HygieneFn>,
762) -> Result<(), Box<dyn std::error::Error>> {
763    heading("Roboticus Binary Update");
764    apply_binary_update(yes, method, false).await?;
765    super::run_oauth_storage_maintenance();
766    let config_path = roboticus_core::config::resolve_config_path(None).unwrap_or_else(|| {
767        roboticus_core::home_dir()
768            .join(".roboticus")
769            .join("roboticus.toml")
770    });
771    super::run_mechanic_checks_maintenance(&config_path.to_string_lossy(), hygiene_fn);
772    println!();
773    Ok(())
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn semver_parse_basic() {
782        assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
783        assert_eq!(parse_semver("v0.1.0"), (0, 1, 0));
784        assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
785    }
786
787    #[test]
788    fn is_newer_works() {
789        assert!(is_newer("0.2.0", "0.1.0"));
790        assert!(is_newer("1.0.0", "0.9.9"));
791        assert!(!is_newer("0.1.0", "0.1.0"));
792        assert!(!is_newer("0.1.0", "0.2.0"));
793    }
794
795    #[test]
796    fn is_newer_patch_bump() {
797        assert!(is_newer("1.0.1", "1.0.0"));
798        assert!(!is_newer("1.0.0", "1.0.1"));
799    }
800
801    #[test]
802    fn is_newer_same_version() {
803        assert!(!is_newer("1.0.0", "1.0.0"));
804    }
805
806    #[test]
807    fn platform_archive_name_supported() {
808        let name = platform_archive_name("1.2.3");
809        if let Some(n) = name {
810            assert!(n.contains("roboticus-1.2.3-"));
811        }
812    }
813
814    #[test]
815    fn parse_semver_partial_version() {
816        assert_eq!(parse_semver("1"), (1, 0, 0));
817        assert_eq!(parse_semver("1.2"), (1, 2, 0));
818    }
819
820    #[test]
821    fn parse_semver_empty() {
822        assert_eq!(parse_semver(""), (0, 0, 0));
823    }
824
825    #[test]
826    fn parse_semver_with_v_prefix() {
827        assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
828    }
829
830    #[test]
831    fn parse_semver_ignores_build_and_prerelease_metadata() {
832        assert_eq!(parse_semver("0.9.4+hotfix.1"), (0, 9, 4));
833        assert_eq!(parse_semver("v1.2.3-rc.1"), (1, 2, 3));
834    }
835
836    #[test]
837    fn parse_sha256sums_for_artifact_finds_exact_entry() {
838        let sums = "\
839abc123  roboticus-0.8.0-darwin-aarch64.tar.gz\n\
840def456  roboticus-0.8.0-linux-x86_64.tar.gz\n";
841        let hash = parse_sha256sums_for_artifact(sums, "roboticus-0.8.0-linux-x86_64.tar.gz");
842        assert_eq!(hash.as_deref(), Some("def456"));
843    }
844
845    #[test]
846    fn find_file_recursive_finds_nested_target() {
847        let dir = tempfile::tempdir().unwrap();
848        let nested = dir.path().join("a").join("b");
849        std::fs::create_dir_all(&nested).unwrap();
850        let target = nested.join("needle.txt");
851        std::fs::write(&target, "x").unwrap();
852        let found = find_file_recursive(dir.path(), "needle.txt").unwrap();
853        assert_eq!(found.as_deref(), Some(target.as_path()));
854    }
855
856    #[test]
857    fn parse_sha256sums_for_artifact_returns_none_when_missing() {
858        let sums = "abc123  file-a.tar.gz\n";
859        assert!(parse_sha256sums_for_artifact(sums, "file-b.tar.gz").is_none());
860    }
861
862    #[test]
863    fn select_release_for_download_prefers_exact_tag() {
864        let archive = platform_archive_name("0.9.4").unwrap();
865        let releases = vec![
866            GitHubRelease {
867                tag_name: "v0.9.4+hotfix.1".into(),
868                draft: false,
869                prerelease: false,
870                published_at: Some("2026-03-05T11:36:51Z".into()),
871                assets: vec![
872                    GitHubAsset {
873                        name: "SHA256SUMS.txt".into(),
874                    },
875                    GitHubAsset {
876                        name: format!(
877                            "roboticus-0.9.4+hotfix.1-{}",
878                            &archive["roboticus-0.9.4-".len()..]
879                        ),
880                    },
881                ],
882            },
883            GitHubRelease {
884                tag_name: "v0.9.4".into(),
885                draft: false,
886                prerelease: false,
887                published_at: Some("2026-03-05T10:00:00Z".into()),
888                assets: vec![
889                    GitHubAsset {
890                        name: "SHA256SUMS.txt".into(),
891                    },
892                    GitHubAsset {
893                        name: archive.clone(),
894                    },
895                ],
896            },
897        ];
898
899        let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
900        assert_eq!(
901            selected.as_ref().map(|(tag, _)| tag.as_str()),
902            Some("v0.9.4")
903        );
904    }
905
906    #[test]
907    fn select_release_for_download_falls_back_to_hotfix_tag() {
908        let archive = platform_archive_name("0.9.4").unwrap();
909        let suffix = &archive["roboticus-0.9.4-".len()..];
910        let releases = vec![
911            GitHubRelease {
912                tag_name: "v0.9.4".into(),
913                draft: false,
914                prerelease: false,
915                published_at: Some("2026-03-05T10:00:00Z".into()),
916                assets: vec![GitHubAsset {
917                    name: "PROVENANCE.json".into(),
918                }],
919            },
920            GitHubRelease {
921                tag_name: "v0.9.4+hotfix.2".into(),
922                draft: false,
923                prerelease: false,
924                published_at: Some("2026-03-05T12:00:00Z".into()),
925                assets: vec![
926                    GitHubAsset {
927                        name: "SHA256SUMS.txt".into(),
928                    },
929                    GitHubAsset {
930                        name: format!("roboticus-0.9.4+hotfix.2-{suffix}"),
931                    },
932                ],
933            },
934        ];
935
936        let selected = select_release_for_download(&releases, "0.9.4", "0.9.3");
937        let expected_archive = format!("roboticus-0.9.4+hotfix.2-{suffix}");
938        assert_eq!(
939            selected.as_ref().map(|(tag, _)| tag.as_str()),
940            Some("v0.9.4+hotfix.2")
941        );
942        assert_eq!(
943            selected
944                .as_ref()
945                .map(|(_, archive_name)| archive_name.as_str()),
946            Some(expected_archive.as_str())
947        );
948    }
949
950    #[test]
951    fn select_release_for_download_falls_back_to_latest_compatible_version() {
952        let archive_010 = platform_archive_name("0.10.0").unwrap();
953        let archive_099 = platform_archive_name("0.9.9").unwrap();
954        let releases = vec![
955            GitHubRelease {
956                tag_name: "v0.10.0".into(),
957                draft: false,
958                prerelease: false,
959                published_at: Some("2026-03-23T12:00:00Z".into()),
960                assets: vec![GitHubAsset {
961                    name: "SHA256SUMS.txt".into(),
962                }],
963            },
964            GitHubRelease {
965                tag_name: "v0.9.9".into(),
966                draft: false,
967                prerelease: false,
968                published_at: Some("2026-03-20T12:00:00Z".into()),
969                assets: vec![
970                    GitHubAsset {
971                        name: "SHA256SUMS.txt".into(),
972                    },
973                    GitHubAsset { name: archive_099 },
974                ],
975            },
976            GitHubRelease {
977                tag_name: "v0.9.8".into(),
978                draft: false,
979                prerelease: false,
980                published_at: Some("2026-03-17T12:00:00Z".into()),
981                assets: vec![
982                    GitHubAsset {
983                        name: "SHA256SUMS.txt".into(),
984                    },
985                    GitHubAsset { name: archive_010 },
986                ],
987            },
988        ];
989
990        let selected = select_release_for_download(&releases, "0.10.0", "0.9.7");
991        assert_eq!(
992            selected.as_ref().map(|(tag, _)| tag.as_str()),
993            Some("v0.9.9")
994        );
995    }
996
997    #[test]
998    fn archive_suffixes_include_macos_alias_for_darwin() {
999        let suffixes = archive_suffixes("aarch64", "darwin", "tar.gz");
1000        assert!(suffixes.contains(&"-aarch64-darwin.tar.gz".to_string()));
1001        assert!(suffixes.contains(&"-aarch64-macos.tar.gz".to_string()));
1002    }
1003
1004    #[test]
1005    fn find_file_recursive_returns_none_when_not_found() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let found = find_file_recursive(dir.path(), "does-not-exist.txt").unwrap();
1008        assert!(found.is_none());
1009    }
1010}