1mod archive;
16mod github;
17mod lock;
18mod ownership;
19mod platform;
20mod version_cache;
21
22use anyhow::{Context, Result};
23use chrono::{DateTime, Utc};
24use semver::Version;
25use std::env;
26use std::fs;
27use std::io::{Read, Seek};
28use std::path::Path;
29use std::process::{Command, Stdio};
30use std::time::{Duration, Instant};
31
32pub use github::{GitHubAsset, GitHubRelease};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AutoUpdateOutcome {
38 Skipped,
40 UpToDate,
42 Updated,
44 Deferred,
46 Failed,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum InstallationDisposition {
52 #[cfg(unix)]
53 Applied,
54 #[cfg(windows)]
55 Deferred,
56}
57
58const CANDIDATE_VALIDATION_TIMEOUT: Duration = Duration::from_secs(5);
59const MAX_VERSION_OUTPUT_BYTES: u64 = 256;
60
61pub fn extract_semver_version(build_version: &str) -> &str {
77 build_version.split('-').next().unwrap_or(build_version)
79}
80
81fn get_current_version() -> Result<(String, Version)> {
91 let full_version = crate::VERSION;
92 let semver_str = extract_semver_version(full_version);
93 let semver_version = Version::parse(semver_str).context(format!(
94 "Failed to parse version from BUILD_VERSION: {}",
95 semver_str
96 ))?;
97
98 Ok((full_version.to_string(), semver_version))
99}
100
101fn parse_latest_version(release: &GitHubRelease) -> Result<Version> {
102 let latest_version_str = release.tag_name.trim_start_matches('v');
103 Version::parse(latest_version_str).context(format!(
104 "Failed to parse latest version: {}",
105 latest_version_str
106 ))
107}
108
109fn get_version_comparison() -> Result<Option<(String, Version, Version, GitHubRelease)>> {
122 let release =
123 github::fetch_latest_release().context("Failed to fetch latest release information")?;
124
125 let (current_version_display, current_version) = get_current_version()?;
126
127 let latest_version = parse_latest_version(&release)?;
128
129 let _ = version_cache::record_version_check(&latest_version.to_string());
132
133 if latest_version <= current_version {
134 println!("Already on the latest version (v{})", current_version);
135 return Ok(None);
136 }
137
138 Ok(Some((
139 current_version_display,
140 current_version,
141 latest_version,
142 release,
143 )))
144}
145
146pub fn check_update() -> Result<Option<String>> {
157 if crate::utils::network_disabled() {
159 return Ok(None);
160 }
161 match get_version_comparison()? {
162 Some((current_version, _, latest_version, release)) => {
163 println!(
164 "Update available: v{} → v{}",
165 extract_semver_version(¤t_version),
166 latest_version
167 );
168 Ok(Some(release.tag_name))
169 }
170 None => Ok(None),
171 }
172}
173
174fn perform_installation_at(
188 current_exe: &Path,
189 latest_version: &Version,
190 release: &GitHubRelease,
191) -> Result<InstallationDisposition> {
192 let asset_pattern = platform::get_asset_pattern(&latest_version.to_string())?;
194 let asset = release
195 .assets
196 .iter()
197 .find(|a| a.name == asset_pattern)
198 .context(format!(
199 "Update failed: No binary found for {} ({})",
200 env::consts::OS,
201 std::env::consts::ARCH
202 ))?;
203
204 let staging = tempfile::Builder::new()
205 .prefix("vct-update-")
206 .tempdir()
207 .context("Update failed: Cannot create temporary directory")?;
208 let archive_path = staging.path().join(&asset.name);
209
210 let downloaded = github::download_file(&asset.browser_download_url, &archive_path)
211 .context("Update failed: Download error")?;
212 if downloaded != asset.size {
213 anyhow::bail!(
214 "Update failed: Downloaded asset size mismatch (expected {}, got {})",
215 asset.size,
216 downloaded
217 );
218 }
219
220 let extract_dir = staging.path().join("extracted");
222 fs::create_dir_all(&extract_dir).context("Update failed: Cannot create temporary directory")?;
223
224 let new_binary = if asset.name.ends_with(".tar.gz") {
225 archive::extract_targz(&archive_path, &extract_dir)
226 .context("Update failed: Cannot extract archive")?
227 } else if asset.name.ends_with(".zip") {
228 archive::extract_zip(&archive_path, &extract_dir)
229 .context("Update failed: Cannot extract archive")?
230 } else {
231 anyhow::bail!("Update failed: Unsupported archive format");
232 };
233
234 #[cfg(unix)]
236 {
237 let staged = platform::stage_update_unix(current_exe, &new_binary)
238 .context("Update failed: Cannot stage binary replacement")?;
239 validate_candidate_binary(&staged, latest_version)
240 .context("Update failed: Candidate binary validation failed")?;
241 platform::commit_update_unix(current_exe, staged)
242 .context("Update failed: Cannot replace binary")?;
243 Ok(InstallationDisposition::Applied)
244 }
245
246 #[cfg(windows)]
247 {
248 validate_candidate_binary(&new_binary, latest_version)
249 .context("Update failed: Candidate binary validation failed")?;
250 let lock_dir = current_exe
251 .parent()
252 .context("Update failed: Current executable has no parent directory")?;
253 platform::perform_update_windows(
254 current_exe,
255 &new_binary,
256 &latest_version.to_string(),
257 &lock::update_lock_path(lock_dir),
258 )
259 .context("Update failed: Cannot stage binary replacement")?;
260 Ok(InstallationDisposition::Deferred)
261 }
262}
263
264fn validate_candidate_binary(candidate: &Path, expected_version: &Version) -> Result<()> {
265 validate_candidate_binary_with_timeout(
266 candidate,
267 expected_version,
268 CANDIDATE_VALIDATION_TIMEOUT,
269 )
270}
271
272fn validate_candidate_binary_with_timeout(
273 candidate: &Path,
274 expected_version: &Version,
275 timeout: Duration,
276) -> Result<()> {
277 let mut stdout =
278 tempfile::tempfile().context("Failed to create candidate validation output")?;
279 let child_stdout = stdout
280 .try_clone()
281 .context("Failed to prepare candidate validation output")?;
282 let mut command = Command::new(candidate);
283 command
284 .arg("--version")
285 .stdin(Stdio::null())
286 .stdout(Stdio::from(child_stdout))
287 .stderr(Stdio::null());
288 #[cfg(windows)]
289 {
290 use std::os::windows::process::CommandExt;
291 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
292 command.creation_flags(CREATE_NO_WINDOW);
293 }
294 let mut child = command
295 .spawn()
296 .context("Failed to execute candidate binary")?;
297 let deadline = Instant::now() + timeout;
298 let status = loop {
299 if let Some(status) = child
300 .try_wait()
301 .context("Failed to wait for candidate binary")?
302 {
303 break status;
304 }
305 if Instant::now() >= deadline {
306 let _ = child.kill();
307 let _ = child.wait();
308 anyhow::bail!("Candidate binary version check timed out");
309 }
310 std::thread::sleep(Duration::from_millis(10));
311 };
312 if !status.success() {
313 anyhow::bail!("Candidate binary exited with {status}");
314 }
315
316 stdout
317 .rewind()
318 .context("Failed to read candidate version output")?;
319 let mut reported = String::new();
320 (&mut stdout)
321 .take(MAX_VERSION_OUTPUT_BYTES + 1)
322 .read_to_string(&mut reported)
323 .context("Candidate version output was not valid UTF-8")?;
324 if reported.len() as u64 > MAX_VERSION_OUTPUT_BYTES {
325 anyhow::bail!("Candidate version output was unexpectedly large");
326 }
327 let reported = reported.trim().trim_start_matches('v');
328 let reported = Version::parse(extract_semver_version(reported))
329 .context("Candidate reported an invalid version")?;
330 if reported != *expected_version {
331 anyhow::bail!("Candidate reported version {reported}, expected {expected_version}");
332 }
333
334 Ok(())
335}
336
337fn print_installation_success(
338 current_version: &str,
339 latest_version: &Version,
340 release: &GitHubRelease,
341 disposition: InstallationDisposition,
342) {
343 match disposition {
344 #[cfg(unix)]
345 InstallationDisposition::Applied => println!(
346 "Upgraded from v{} to v{}",
347 extract_semver_version(current_version),
348 latest_version
349 ),
350 #[cfg(windows)]
351 InstallationDisposition::Deferred => println!(
352 "Staged upgrade from v{} to v{}; it will be applied after this command exits",
353 extract_semver_version(current_version),
354 latest_version
355 ),
356 }
357 println!(
358 "https://github.com/Mai0313/VibeCodingTracker/releases/tag/{}",
359 release.tag_name
360 );
361 println!(
362 "If you like this tool, please star us on GitHub: https://github.com/Mai0313/VibeCodingTracker"
363 );
364}
365
366fn install_and_report(
367 current_version: &str,
368 latest_version: &Version,
369 release: &GitHubRelease,
370) -> Result<()> {
371 let current_exe = env::current_exe()
372 .context("Update failed: Cannot locate current executable")?
373 .canonicalize()
374 .context("Update failed: Cannot resolve current executable")?;
375 let disposition = perform_installation_at(¤t_exe, latest_version, release)?;
376 print_installation_success(current_version, latest_version, release, disposition);
377 Ok(())
378}
379
380pub fn perform_update() -> Result<()> {
391 let _lock = acquire_update_lock()?;
392 perform_update_unlocked()
393}
394
395fn perform_update_unlocked() -> Result<()> {
396 let Some((current_version, _, latest_version, release)) = get_version_comparison()? else {
398 return Ok(());
400 };
401
402 install_and_report(¤t_version, &latest_version, &release)
403}
404
405pub fn perform_force_update() -> Result<()> {
416 let _lock = acquire_update_lock()?;
417 perform_force_update_unlocked()
418}
419
420fn perform_force_update_unlocked() -> Result<()> {
421 let release =
422 github::fetch_latest_release().context("Failed to fetch latest release information")?;
423
424 let (current_version_display, _) = get_current_version()?;
425
426 let latest_version = parse_latest_version(&release)?;
427
428 let _ = version_cache::record_version_check(&latest_version.to_string());
429
430 install_and_report(¤t_version_display, &latest_version, &release)
431}
432
433fn acquire_update_lock() -> Result<lock::UpdateLock> {
434 let current_exe = env::current_exe()
435 .context("Update failed: Cannot locate current executable")?
436 .canonicalize()
437 .context("Update failed: Cannot resolve current executable")?;
438 acquire_update_lock_at(¤t_exe)
439}
440
441fn acquire_update_lock_at(current_exe: &Path) -> Result<lock::UpdateLock> {
442 let lock_dir = current_exe
443 .parent()
444 .context("Update failed: Current executable has no parent directory")?;
445 lock::UpdateLock::try_acquire(lock_dir)?
446 .context("Another vct update is already running; try again after it exits")
447}
448
449pub fn maybe_auto_update(enabled: bool) -> AutoUpdateOutcome {
457 let offline = crate::utils::network_disabled();
458 let Ok(current_exe) = env::current_exe() else {
459 return AutoUpdateOutcome::Failed;
460 };
461 let managed = ownership::is_release_managed(¤t_exe);
462 if !auto_update_allowed(enabled, offline, managed) {
463 return AutoUpdateOutcome::Skipped;
464 }
465
466 let result = (|| -> Result<AutoUpdateOutcome> {
467 let current_exe = current_exe
468 .canonicalize()
469 .context("Failed to resolve release-managed executable")?;
470 let lock_dir = current_exe
471 .parent()
472 .context("Release-managed executable has no parent directory")?;
473 let cache_dir = crate::utils::get_cache_dir()?;
474 let (_, current_version) = get_current_version()?;
475 auto_update_with(
476 &cache_dir,
477 lock_dir,
478 ¤t_version,
479 Utc::now(),
480 github::fetch_latest_release,
481 |release, latest| perform_installation_at(¤t_exe, latest, release),
482 )
483 })();
484
485 match result {
486 Ok(outcome) => outcome,
487 Err(error) => {
488 log::warn!("startup auto-update failed: {error:#}");
489 AutoUpdateOutcome::Failed
490 }
491 }
492}
493
494fn auto_update_allowed(enabled: bool, offline: bool, release_managed: bool) -> bool {
495 enabled && !offline && release_managed
496}
497
498fn auto_update_with(
499 cache_dir: &Path,
500 lock_dir: &Path,
501 current_version: &Version,
502 now: DateTime<Utc>,
503 fetch_release: impl FnOnce() -> Result<GitHubRelease>,
504 install_release: impl FnOnce(&GitHubRelease, &Version) -> Result<InstallationDisposition>,
505) -> Result<AutoUpdateOutcome> {
506 let Some(_lock) = lock::UpdateLock::try_acquire(lock_dir)? else {
507 log::warn!("startup auto-update skipped because another update is running");
508 return Ok(AutoUpdateOutcome::Skipped);
509 };
510
511 let record = version_cache::read_self_version_in(cache_dir);
512 if !version_cache::check_is_due(&record, now) {
513 return Ok(AutoUpdateOutcome::Skipped);
514 }
515
516 version_cache::record_check_attempt_in(cache_dir, now)?;
519 let release = fetch_release().context("Failed to fetch latest release information")?;
520 let latest_version = parse_latest_version(&release)?;
521 if let Err(error) =
522 version_cache::record_version_result_in(cache_dir, &latest_version.to_string(), now)
523 {
524 log::warn!("failed to record startup update result: {error:#}");
525 }
526
527 if latest_version <= *current_version {
528 return Ok(AutoUpdateOutcome::UpToDate);
529 }
530
531 match install_release(&release, &latest_version)? {
532 #[cfg(unix)]
533 InstallationDisposition::Applied => Ok(AutoUpdateOutcome::Updated),
534 #[cfg(windows)]
535 InstallationDisposition::Deferred => Ok(AutoUpdateOutcome::Deferred),
536 }
537}
538
539pub fn update_interactive(force: bool) -> Result<()> {
552 println!("Checking for updates...");
553
554 if force {
555 perform_force_update()
557 } else {
558 if crate::utils::network_disabled() {
560 return Ok(());
561 }
562 if let Some((current_version, _, latest_version, _release)) = get_version_comparison()? {
563 println!(
564 "Update available: v{} → v{}",
565 extract_semver_version(¤t_version),
566 latest_version
567 );
568 print!("Continue? (y/N): ");
569 std::io::Write::flush(&mut std::io::stdout())?;
570
571 let mut input = String::new();
572 std::io::stdin().read_line(&mut input)?;
573
574 if !input.trim().eq_ignore_ascii_case("y") {
575 println!("Cancelled");
576 return Ok(());
577 }
578 perform_update()
579 } else {
580 Ok(())
581 }
582 }
583}
584
585#[doc(hidden)]
587pub use archive::{extract_targz, extract_zip};
588#[doc(hidden)]
589pub use platform::get_asset_pattern;
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 #[cfg(unix)]
595 use flate2::{Compression, write::GzEncoder};
596 #[cfg(unix)]
597 use httpmock::prelude::*;
598 use std::cell::Cell;
599
600 fn release(tag: &str) -> GitHubRelease {
601 GitHubRelease {
602 tag_name: tag.into(),
603 name: tag.into(),
604 body: None,
605 assets: Vec::new(),
606 }
607 }
608
609 #[cfg(unix)]
610 fn release_archive(contents: &[u8]) -> Vec<u8> {
611 let encoder = GzEncoder::new(Vec::new(), Compression::default());
612 let mut archive = tar::Builder::new(encoder);
613 let mut header = tar::Header::new_gnu();
614 header.set_size(contents.len() as u64);
615 header.set_mode(0o755);
616 header.set_cksum();
617 archive
618 .append_data(&mut header, "vibe_coding_tracker", contents)
619 .unwrap();
620 archive.into_inner().unwrap().finish().unwrap()
621 }
622
623 #[cfg(unix)]
624 fn release_binary(version: &str) -> Vec<u8> {
625 format!("#!/bin/sh\nprintf '%s\\n' '{version}'\n").into_bytes()
626 }
627
628 #[cfg(unix)]
629 #[test]
630 fn candidate_validation_has_a_bounded_timeout() {
631 use std::os::unix::fs::PermissionsExt;
632
633 let dir = tempfile::tempdir().unwrap();
634 let candidate = dir.path().join("candidate");
635 fs::write(&candidate, b"#!/bin/sh\nwhile :; do :; done\n").unwrap();
636 fs::set_permissions(&candidate, fs::Permissions::from_mode(0o755)).unwrap();
637 let started = Instant::now();
638
639 let error = validate_candidate_binary_with_timeout(
640 &candidate,
641 &Version::new(1, 1, 0),
642 Duration::from_millis(20),
643 )
644 .expect_err("a hanging candidate must be rejected");
645
646 assert!(error.to_string().contains("timed out"));
647 assert!(started.elapsed() < Duration::from_secs(1));
648 }
649
650 #[test]
651 fn test_extract_semver_version_clean() {
652 let version = "0.1.6";
654 assert_eq!(extract_semver_version(version), "0.1.6");
655 }
656
657 #[test]
658 fn test_extract_semver_version_with_git_metadata() {
659 let version = "0.1.6-5-g1234567";
661 assert_eq!(extract_semver_version(version), "0.1.6");
662 }
663
664 #[test]
665 fn test_extract_semver_version_with_dirty_flag() {
666 let version = "0.1.6-5-g1234567-dirty";
668 assert_eq!(extract_semver_version(version), "0.1.6");
669 }
670
671 #[test]
672 fn test_extract_semver_version_rc() {
673 let version = "1.0.0-rc.1";
675 assert_eq!(extract_semver_version(version), "1.0.0");
676 }
677
678 #[test]
679 fn test_extract_semver_version_beta() {
680 let version = "2.3.4-beta.2";
682 assert_eq!(extract_semver_version(version), "2.3.4");
683 }
684
685 #[test]
686 fn test_extract_semver_version_alpha() {
687 let version = "0.5.0-alpha";
689 assert_eq!(extract_semver_version(version), "0.5.0");
690 }
691
692 #[test]
693 fn test_extract_semver_version_complex() {
694 let version = "1.2.3-45-gabcdef0-modified";
696 assert_eq!(extract_semver_version(version), "1.2.3");
697 }
698
699 #[test]
700 fn test_extract_semver_version_single_digit() {
701 assert_eq!(extract_semver_version("1.0.0"), "1.0.0");
703 assert_eq!(extract_semver_version("0.0.1"), "0.0.1");
704 }
705
706 #[test]
707 fn test_extract_semver_version_large_numbers() {
708 assert_eq!(extract_semver_version("10.20.30"), "10.20.30");
710 assert_eq!(extract_semver_version("100.200.300-1-g123"), "100.200.300");
711 }
712
713 #[test]
714 fn test_extract_semver_version_empty() {
715 assert_eq!(extract_semver_version(""), "");
717 }
718
719 #[test]
720 fn test_extract_semver_version_no_dashes() {
721 let version = "2.4.8";
723 assert_eq!(extract_semver_version(version), "2.4.8");
724 }
725
726 #[test]
727 fn test_extract_semver_version_multiple_dashes() {
728 let version = "1.0.0-pre-release-candidate";
730 assert_eq!(extract_semver_version(version), "1.0.0");
731 }
732
733 #[test]
734 fn test_extract_semver_version_only_major_minor() {
735 let version = "1.2";
737 assert_eq!(extract_semver_version(version), "1.2");
738 }
739
740 #[test]
741 fn test_extract_semver_version_with_v_prefix() {
742 let version = "v1.2.3-dirty";
745 assert_eq!(extract_semver_version(version), "v1.2.3");
746 }
747
748 #[test]
749 fn test_extract_semver_version_consistency() {
750 let version = "3.1.4-15-g926535-dirty";
752 let result1 = extract_semver_version(version);
753 let result2 = extract_semver_version(version);
754 assert_eq!(result1, result2);
755 }
756
757 #[test]
758 fn test_extract_semver_version_zero_version() {
759 assert_eq!(extract_semver_version("0.0.0"), "0.0.0");
761 assert_eq!(extract_semver_version("0.0.0-dev"), "0.0.0");
762 }
763
764 #[test]
765 fn test_extract_semver_version_patch_zero() {
766 assert_eq!(extract_semver_version("1.5.0"), "1.5.0");
768 assert_eq!(extract_semver_version("2.0.0-rc1"), "2.0.0");
769 }
770
771 #[test]
772 fn startup_policy_requires_all_three_guards() {
773 assert!(auto_update_allowed(true, false, true));
774 assert!(!auto_update_allowed(false, false, true));
775 assert!(!auto_update_allowed(true, true, true));
776 assert!(!auto_update_allowed(true, false, false));
777 }
778
779 #[test]
780 fn current_day_cache_skips_fetch_and_install() {
781 let dir = tempfile::tempdir().unwrap();
782 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
783 version_cache::record_check_attempt_in(dir.path(), now).unwrap();
784
785 let outcome = auto_update_with(
786 dir.path(),
787 dir.path(),
788 &Version::new(1, 0, 0),
789 now,
790 || panic!("fetch must be skipped"),
791 |_, _| panic!("install must be skipped"),
792 )
793 .unwrap();
794
795 assert_eq!(outcome, AutoUpdateOutcome::Skipped);
796 }
797
798 #[test]
799 fn failed_fetch_is_throttled_for_the_rest_of_the_day() {
800 let dir = tempfile::tempdir().unwrap();
801 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
802 let fetches = Cell::new(0);
803
804 let first = auto_update_with(
805 dir.path(),
806 dir.path(),
807 &Version::new(1, 0, 0),
808 now,
809 || {
810 fetches.set(fetches.get() + 1);
811 anyhow::bail!("offline")
812 },
813 |_, _| panic!("install must be skipped"),
814 );
815 assert!(first.is_err());
816
817 let second = auto_update_with(
818 dir.path(),
819 dir.path(),
820 &Version::new(1, 0, 0),
821 now,
822 || {
823 fetches.set(fetches.get() + 1);
824 Ok(release("v2.0.0"))
825 },
826 |_, _| panic!("install must be skipped"),
827 )
828 .unwrap();
829 assert_eq!(second, AutoUpdateOutcome::Skipped);
830 assert_eq!(fetches.get(), 1);
831 }
832
833 #[cfg(unix)]
834 #[test]
835 fn newer_release_installs_once_and_records_the_version() {
836 let dir = tempfile::tempdir().unwrap();
837 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
838 let installs = Cell::new(0);
839
840 let outcome = auto_update_with(
841 dir.path(),
842 dir.path(),
843 &Version::new(1, 0, 0),
844 now,
845 || Ok(release("v1.1.0")),
846 |_, latest| {
847 installs.set(installs.get() + 1);
848 assert_eq!(latest, &Version::new(1, 1, 0));
849 Ok(InstallationDisposition::Applied)
850 },
851 )
852 .unwrap();
853
854 assert_eq!(outcome, AutoUpdateOutcome::Updated);
855 assert_eq!(installs.get(), 1);
856 assert_eq!(
857 version_cache::read_self_version_in(dir.path())
858 .latest_version
859 .as_deref(),
860 Some("1.1.0")
861 );
862
863 let second = auto_update_with(
864 dir.path(),
865 dir.path(),
866 &Version::new(1, 0, 0),
867 now,
868 || panic!("the same UTC date must not fetch twice"),
869 |_, _| panic!("the same UTC date must not install twice"),
870 )
871 .unwrap();
872 assert_eq!(second, AutoUpdateOutcome::Skipped);
873 assert_eq!(installs.get(), 1);
874 }
875
876 #[test]
877 fn current_release_never_calls_installer() {
878 let dir = tempfile::tempdir().unwrap();
879 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
880
881 let outcome = auto_update_with(
882 dir.path(),
883 dir.path(),
884 &Version::new(1, 1, 0),
885 now,
886 || Ok(release("v1.1.0")),
887 |_, _| panic!("current release must not install"),
888 )
889 .unwrap();
890
891 assert_eq!(outcome, AutoUpdateOutcome::UpToDate);
892 }
893
894 #[test]
895 fn lock_contention_skips_without_fetching() {
896 let dir = tempfile::tempdir().unwrap();
897 let _lock = lock::UpdateLock::try_acquire(dir.path())
898 .unwrap()
899 .expect("lock");
900 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
901
902 let outcome = auto_update_with(
903 dir.path(),
904 dir.path(),
905 &Version::new(1, 0, 0),
906 now,
907 || panic!("contended process must not fetch"),
908 |_, _| panic!("contended process must not install"),
909 )
910 .unwrap();
911
912 assert_eq!(outcome, AutoUpdateOutcome::Skipped);
913 }
914
915 #[test]
916 fn manual_update_lock_lives_beside_the_executable() {
917 let install_dir = tempfile::tempdir().unwrap();
918 let executable = install_dir.path().join("vibe_coding_tracker");
919 fs::write(&executable, b"binary").unwrap();
920
921 let first = acquire_update_lock_at(&executable).unwrap();
922 assert!(install_dir.path().join(".vct-update.lock").exists());
923 assert!(acquire_update_lock_at(&executable).is_err());
924 drop(first);
925 assert!(acquire_update_lock_at(&executable).is_ok());
926 }
927
928 #[test]
929 fn invalid_cache_path_fails_before_fetch() {
930 let root = tempfile::tempdir().unwrap();
931 let missing = root.path().join("missing");
932 fs::write(&missing, b"not a directory").unwrap();
933 let now = "2026-07-30T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
934
935 let result = auto_update_with(
936 &missing,
937 root.path(),
938 &Version::new(1, 0, 0),
939 now,
940 || panic!("failed claim must not fetch"),
941 |_, _| panic!("failed claim must not install"),
942 );
943
944 assert!(result.is_err());
945 }
946
947 #[cfg(unix)]
948 #[test]
949 fn installation_downloads_and_atomically_replaces_the_target() {
950 let candidate = release_binary("1.1.0");
951 let archive = release_archive(&candidate);
952 let server = MockServer::start();
953 server.mock(|when, then| {
954 when.method(GET).path("/asset");
955 then.status(200).body(archive.clone());
956 });
957 let dir = tempfile::tempdir().unwrap();
958 let target = dir.path().join("vibe_coding_tracker");
959 fs::write(&target, b"old binary").unwrap();
960 let latest = Version::new(1, 1, 0);
961 let release = GitHubRelease {
962 tag_name: "v1.1.0".into(),
963 name: "v1.1.0".into(),
964 body: None,
965 assets: vec![GitHubAsset {
966 name: platform::get_asset_pattern("1.1.0").unwrap(),
967 browser_download_url: server.url("/asset"),
968 size: archive.len() as u64,
969 }],
970 };
971
972 let disposition = perform_installation_at(&target, &latest, &release).unwrap();
973
974 assert_eq!(disposition, InstallationDisposition::Applied);
975 assert_eq!(fs::read(&target).unwrap(), candidate);
976 }
977
978 #[cfg(unix)]
979 #[test]
980 fn invalid_candidate_never_replaces_the_target() {
981 let archive = release_archive(b"not an executable format");
982 let server = MockServer::start();
983 server.mock(|when, then| {
984 when.method(GET).path("/asset");
985 then.status(200).body(archive.clone());
986 });
987 let dir = tempfile::tempdir().unwrap();
988 let target = dir.path().join("vibe_coding_tracker");
989 fs::write(&target, b"old binary").unwrap();
990 let release = GitHubRelease {
991 tag_name: "v1.1.0".into(),
992 name: "v1.1.0".into(),
993 body: None,
994 assets: vec![GitHubAsset {
995 name: platform::get_asset_pattern("1.1.0").unwrap(),
996 browser_download_url: server.url("/asset"),
997 size: archive.len() as u64,
998 }],
999 };
1000
1001 assert!(perform_installation_at(&target, &Version::new(1, 1, 0), &release).is_err());
1002 assert_eq!(fs::read(&target).unwrap(), b"old binary");
1003 }
1004
1005 #[cfg(unix)]
1006 #[test]
1007 fn wrong_candidate_version_never_replaces_the_target() {
1008 let archive = release_archive(&release_binary("1.0.0"));
1009 let server = MockServer::start();
1010 server.mock(|when, then| {
1011 when.method(GET).path("/asset");
1012 then.status(200).body(archive.clone());
1013 });
1014 let dir = tempfile::tempdir().unwrap();
1015 let target = dir.path().join("vibe_coding_tracker");
1016 fs::write(&target, b"old binary").unwrap();
1017 let release = GitHubRelease {
1018 tag_name: "v1.1.0".into(),
1019 name: "v1.1.0".into(),
1020 body: None,
1021 assets: vec![GitHubAsset {
1022 name: platform::get_asset_pattern("1.1.0").unwrap(),
1023 browser_download_url: server.url("/asset"),
1024 size: archive.len() as u64,
1025 }],
1026 };
1027
1028 assert!(perform_installation_at(&target, &Version::new(1, 1, 0), &release).is_err());
1029 assert_eq!(fs::read(&target).unwrap(), b"old binary");
1030 }
1031
1032 #[cfg(unix)]
1033 #[test]
1034 fn size_mismatch_never_replaces_the_target() {
1035 let archive = release_archive(b"truncated binary");
1036 let server = MockServer::start();
1037 server.mock(|when, then| {
1038 when.method(GET).path("/asset");
1039 then.status(200).body(archive.clone());
1040 });
1041 let dir = tempfile::tempdir().unwrap();
1042 let target = dir.path().join("vibe_coding_tracker");
1043 fs::write(&target, b"old binary").unwrap();
1044 let release = GitHubRelease {
1045 tag_name: "v1.1.0".into(),
1046 name: "v1.1.0".into(),
1047 body: None,
1048 assets: vec![GitHubAsset {
1049 name: platform::get_asset_pattern("1.1.0").unwrap(),
1050 browser_download_url: server.url("/asset"),
1051 size: archive.len() as u64 + 1,
1052 }],
1053 };
1054
1055 assert!(perform_installation_at(&target, &Version::new(1, 1, 0), &release).is_err());
1056 assert_eq!(fs::read(&target).unwrap(), b"old binary");
1057 }
1058}