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 let exe = std::env::current_exe()?;
233
234 #[cfg(windows)]
235 {
236 let old_exe = exe.with_extension("exe.old");
240
241 let _ = std::fs::remove_file(&old_exe);
243
244 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 if let Err(e) = std::fs::write(&exe, bytes) {
255 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
285pub 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#[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) .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
621pub(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
761pub 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}