1use 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
17pub(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) .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#[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) .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
615pub(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
755pub 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}