Skip to main content

vct_core/update/
mod.rs

1//! Self-update: replace the running binary from the matching GitHub release.
2//!
3//! The flow is: resolve the current host's `(os, arch, libc)` tuple, fetch the
4//! latest GitHub Releases tag, pick the asset whose name matches that tuple,
5//! download and extract it (zip on Windows, tar.gz elsewhere), then atomically
6//! swap it in over the current executable. [`check_update`] is a read-only
7//! probe; [`update_interactive`] is the entry point the `vct update`
8//! subcommand calls.
9//!
10//! Submodules: `github` (Releases API + download), `archive` (extraction with
11//! path-traversal guards), `lock` (cross-process serialization), `ownership`
12//! (official-installer marker), `platform` (asset-name derivation and the
13//! OS-specific binary swap), and `version_cache` (daily check cadence).
14
15mod 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
32// Re-export public types for backward compatibility
33pub use github::{GitHubAsset, GitHubRelease};
34
35/// Result of the best-effort startup update hook.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AutoUpdateOutcome {
38    /// The hook was disabled, ineligible, already claimed, or not due today.
39    Skipped,
40    /// A daily check completed and the running version is current.
41    UpToDate,
42    /// The executable was replaced. Unix callers should re-exec their command.
43    Updated,
44    /// Windows staged a replacement that will run after this process exits.
45    Deferred,
46    /// A best-effort check or install failed; the caller should continue.
47    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
61/// Strips git metadata from a `BUILD_VERSION` string, leaving the base semver.
62///
63/// `BUILD_VERSION` may carry a `git describe` suffix such as
64/// `"0.1.6-5-g1234567-dirty"`; this returns just `"0.1.6"` by taking the text
65/// before the first `-`. A `v` prefix is *not* stripped — callers do that
66/// separately when comparing against a release tag.
67///
68/// # Examples
69///
70/// ```
71/// use vct_core::update::extract_semver_version;
72///
73/// assert_eq!(extract_semver_version("0.1.6-5-g1234567-dirty"), "0.1.6");
74/// assert_eq!(extract_semver_version("2.4.8"), "2.4.8");
75/// ```
76pub fn extract_semver_version(build_version: &str) -> &str {
77    // Split by '-' and take the first part (the base version)
78    build_version.split('-').next().unwrap_or(build_version)
79}
80
81/// Returns the running build's version as `(display string, parsed semver)`.
82///
83/// The display string is the raw [`crate::VERSION`] (with any git suffix); the
84/// parsed [`Version`] is the cleaned base version used for comparison.
85///
86/// # Errors
87///
88/// Returns an error if the base version extracted from [`crate::VERSION`] is
89/// not valid semver.
90fn 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
109/// Fetches the latest release and compares it against the running version.
110///
111/// Returns `Some((current_display, current, latest, release))` when the latest
112/// tag is strictly newer than the current version, or `None` when already up to
113/// date (also printing a short "already on latest" line in that case). The
114/// release tag's leading `v` is trimmed before parsing.
115///
116/// # Errors
117///
118/// Returns an error if the GitHub release fetch fails, if the current version
119/// cannot be parsed (see `get_current_version`), or if the release tag is not
120/// valid semver.
121fn 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    // Record the check regardless of whether a newer version exists.
130    // Best-effort: a write failure never blocks an explicit update.
131    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
146/// Probes for a newer release without installing anything.
147///
148/// Prints an "update available" line and returns `Some(tag_name)` when a newer
149/// release exists, or `None` when already current. This is the read-only path
150/// behind `vct update --check`.
151///
152/// # Errors
153///
154/// Returns an error if the version comparison fails — i.e. the GitHub fetch or
155/// any version parse fails (see `get_version_comparison`).
156pub fn check_update() -> Result<Option<String>> {
157    // Offline mode: skip the GitHub Releases probe entirely.
158    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(&current_version),
166                latest_version
167            );
168            Ok(Some(release.tag_name))
169        }
170        None => Ok(None),
171    }
172}
173
174/// Downloads, extracts, and installs a specific `release`, no version check.
175///
176/// Selects the asset matching the current platform, downloads it into a unique
177/// temporary directory, checks its exact size, extracts it, and executes its
178/// bounded `--version` probe before swapping it over the running executable.
179/// Unix uses an atomic rename; Windows stages a post-exit helper.
180///
181/// # Errors
182///
183/// Returns an error if no release asset matches this `(os, arch)`, if the
184/// staging directory cannot be created, if the download size is wrong, if the
185/// archive is invalid, if the candidate cannot report the expected version
186/// within the timeout, or if replacing the binary fails.
187fn perform_installation_at(
188    current_exe: &Path,
189    latest_version: &Version,
190    release: &GitHubRelease,
191) -> Result<InstallationDisposition> {
192    // Find the asset for current platform
193    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    // Extract the archive
221    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    // Perform platform-specific update
235    #[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(&current_exe, latest_version, release)?;
376    print_installation_success(current_version, latest_version, release, disposition);
377    Ok(())
378}
379
380/// Installs the latest release, but only if it is newer than the current one.
381///
382/// Returns `Ok(())` without doing anything when already up to date. Works
383/// regardless of how the binary was installed (npm/pip/cargo/manual), because
384/// every channel ships the same pre-compiled GitHub release binaries.
385///
386/// # Errors
387///
388/// Returns an error if the version comparison fails (GitHub fetch or version
389/// parse) or if the subsequent install fails (see `perform_installation_at`).
390pub fn perform_update() -> Result<()> {
391    let _lock = acquire_update_lock()?;
392    perform_update_unlocked()
393}
394
395fn perform_update_unlocked() -> Result<()> {
396    // Get version comparison
397    let Some((current_version, _, latest_version, release)) = get_version_comparison()? else {
398        // Already on latest version
399        return Ok(());
400    };
401
402    install_and_report(&current_version, &latest_version, &release)
403}
404
405/// Installs the latest release unconditionally, skipping the freshness check.
406///
407/// Always re-downloads and reinstalls the latest tag even when the current
408/// binary already matches it (useful for repairing a broken install).
409///
410/// # Errors
411///
412/// Returns an error if the GitHub release fetch fails, if the current or
413/// latest version cannot be parsed, or if the install fails (see
414/// `perform_installation_at`, notably when no asset matches this platform).
415pub 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(&current_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(&current_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
449/// Checks for and silently installs a newer release when startup policy allows.
450///
451/// This is a best-effort hook for ordinary commands. It performs no work when
452/// disabled, offline, already checked on the current UTC date, or when the
453/// executable lacks the official release installer's ownership marker. Every
454/// error is logged and converted to [`AutoUpdateOutcome::Failed`] so it can
455/// never block the requested command.
456pub 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(&current_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            &current_version,
479            Utc::now(),
480            github::fetch_latest_release,
481            |release, latest| perform_installation_at(&current_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    // Claim today's attempt before touching the network. If GitHub is down, the
517    // failed request is still throttled until the next UTC date.
518    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
539/// Runs the `vct update` flow, optionally prompting for confirmation.
540///
541/// With `force` set, skips the freshness check and the prompt and reinstalls
542/// the latest release outright. Otherwise it checks for a newer version and,
543/// only if one exists, asks for `y`/`N` confirmation on stdin before
544/// installing — anything other than `y` cancels.
545///
546/// # Errors
547///
548/// Returns an error if the update check or install fails (network, version
549/// parse, asset selection, extraction, or binary swap), or if reading the
550/// confirmation from stdin fails.
551pub fn update_interactive(force: bool) -> Result<()> {
552    println!("Checking for updates...");
553
554    if force {
555        // Force update: skip version check, always download latest
556        perform_force_update()
557    } else {
558        // Normal update: check version and prompt for confirmation
559        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(&current_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// Re-export functions for testing
586#[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        // Test extracting clean semver version
653        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        // Test extracting version with git metadata
660        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        // Test extracting version with dirty flag
667        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        // Test extracting release candidate version
674        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        // Test extracting beta version
681        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        // Test extracting alpha version
688        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        // Test extracting complex version string
695        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        // Test single digit versions
702        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        // Test with large version numbers
709        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        // Test with empty string (edge case)
716        assert_eq!(extract_semver_version(""), "");
717    }
718
719    #[test]
720    fn test_extract_semver_version_no_dashes() {
721        // Test version without any dashes
722        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        // Test with multiple dashes
729        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        // Test incomplete version (not standard semver, but should handle gracefully)
736        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        // Test with v prefix (common in git tags)
743        // Note: This function doesn't strip 'v', that's done elsewhere
744        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        // Test that calling twice gives same result
751        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        // Test zero versions
760        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        // Test with patch version zero
767        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}