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), and `platform` (asset-name derivation and the
12//! OS-specific binary swap).
13
14mod archive;
15mod github;
16mod platform;
17mod version_cache;
18
19use anyhow::{Context, Result};
20use semver::Version;
21use std::env;
22use std::fs;
23
24// Re-export public types for backward compatibility
25pub use github::{GitHubAsset, GitHubRelease};
26
27/// Strips git metadata from a `BUILD_VERSION` string, leaving the base semver.
28///
29/// `BUILD_VERSION` may carry a `git describe` suffix such as
30/// `"0.1.6-5-g1234567-dirty"`; this returns just `"0.1.6"` by taking the text
31/// before the first `-`. A `v` prefix is *not* stripped — callers do that
32/// separately when comparing against a release tag.
33///
34/// # Examples
35///
36/// ```
37/// use vct_core::update::extract_semver_version;
38///
39/// assert_eq!(extract_semver_version("0.1.6-5-g1234567-dirty"), "0.1.6");
40/// assert_eq!(extract_semver_version("2.4.8"), "2.4.8");
41/// ```
42pub fn extract_semver_version(build_version: &str) -> &str {
43    // Split by '-' and take the first part (the base version)
44    build_version.split('-').next().unwrap_or(build_version)
45}
46
47/// Returns the running build's version as `(display string, parsed semver)`.
48///
49/// The display string is the raw [`crate::VERSION`] (with any git suffix); the
50/// parsed [`Version`] is the cleaned base version used for comparison.
51///
52/// # Errors
53///
54/// Returns an error if the base version extracted from [`crate::VERSION`] is
55/// not valid semver.
56fn get_current_version() -> Result<(String, Version)> {
57    let full_version = crate::VERSION;
58    let semver_str = extract_semver_version(full_version);
59    let semver_version = Version::parse(semver_str).context(format!(
60        "Failed to parse version from BUILD_VERSION: {}",
61        semver_str
62    ))?;
63
64    Ok((full_version.to_string(), semver_version))
65}
66
67/// Fetches the latest release and compares it against the running version.
68///
69/// Returns `Some((current_display, current, latest, release))` when the latest
70/// tag is strictly newer than the current version, or `None` when already up to
71/// date (also printing a short "already on latest" line in that case). The
72/// release tag's leading `v` is trimmed before parsing.
73///
74/// # Errors
75///
76/// Returns an error if the GitHub release fetch fails, if the current version
77/// cannot be parsed (see `get_current_version`), or if the release tag is not
78/// valid semver.
79fn get_version_comparison() -> Result<Option<(String, Version, Version, GitHubRelease)>> {
80    let release =
81        github::fetch_latest_release().context("Failed to fetch latest release information")?;
82
83    let (current_version_display, current_version) = get_current_version()?;
84
85    // Remove 'v' prefix if present and parse as semver
86    let latest_version_str = release.tag_name.trim_start_matches('v');
87    let latest_version = Version::parse(latest_version_str).context(format!(
88        "Failed to parse latest version: {}",
89        latest_version_str
90    ))?;
91
92    // Record the check for the future auto-update prompt, regardless of whether
93    // a newer version exists. Best-effort: a write failure never blocks update.
94    let _ = version_cache::record_version_check(&latest_version.to_string());
95
96    if latest_version <= current_version {
97        println!("Already on the latest version (v{})", current_version);
98        return Ok(None);
99    }
100
101    Ok(Some((
102        current_version_display,
103        current_version,
104        latest_version,
105        release,
106    )))
107}
108
109/// Probes for a newer release without installing anything.
110///
111/// Prints an "update available" line and returns `Some(tag_name)` when a newer
112/// release exists, or `None` when already current. This is the read-only path
113/// behind `vct update --check`.
114///
115/// # Errors
116///
117/// Returns an error if the version comparison fails — i.e. the GitHub fetch or
118/// any version parse fails (see `get_version_comparison`).
119pub fn check_update() -> Result<Option<String>> {
120    // Offline mode: skip the GitHub Releases probe entirely.
121    if crate::utils::network_disabled() {
122        return Ok(None);
123    }
124    match get_version_comparison()? {
125        Some((current_version, _, latest_version, release)) => {
126            println!(
127                "Update available: v{} → v{}",
128                extract_semver_version(&current_version),
129                latest_version
130            );
131            Ok(Some(release.tag_name))
132        }
133        None => Ok(None),
134    }
135}
136
137/// Downloads, extracts, and installs a specific `release`, no version check.
138///
139/// Selects the asset matching the current platform, downloads it to the temp
140/// dir, extracts it into a freshly recreated `vct_update/` staging directory,
141/// swaps the new binary in over the running executable (Unix rename or the
142/// Windows deferred-batch strategy), and then best-effort cleans up the
143/// downloaded archive and staging dir. `current_version` is the display string
144/// used only in the success message; `latest_version` is what the binary will
145/// become.
146///
147/// # Errors
148///
149/// Returns an error if no release asset matches this `(os, arch)`, if the
150/// current executable path cannot be resolved, if the staging directory cannot
151/// be cleaned or created, if the download fails, if the archive format is
152/// unsupported or extraction fails, or if replacing the binary fails. Cleanup
153/// of the temporary files is best-effort and never surfaced as an error.
154fn perform_installation(
155    current_version: &str,
156    latest_version: &Version,
157    release: &GitHubRelease,
158) -> Result<()> {
159    // Find the asset for current platform
160    let asset_pattern = platform::get_asset_pattern(&latest_version.to_string())?;
161    let asset = release
162        .assets
163        .iter()
164        .find(|a| a.name == asset_pattern)
165        .context(format!(
166            "Update failed: No binary found for {} ({})",
167            env::consts::OS,
168            std::env::consts::ARCH
169        ))?;
170
171    // Get current executable path
172    let current_exe =
173        env::current_exe().context("Update failed: Cannot locate current executable")?;
174
175    // Download to temporary location
176    let temp_dir = env::temp_dir();
177    let archive_path = temp_dir.join(&asset.name);
178
179    github::download_file(&asset.browser_download_url, &archive_path)
180        .context("Update failed: Download error")?;
181
182    // Extract the archive
183    let extract_dir = temp_dir.join("vct_update");
184    if extract_dir.exists() {
185        fs::remove_dir_all(&extract_dir)
186            .context("Update failed: Cannot clean temporary directory")?;
187    }
188    fs::create_dir_all(&extract_dir).context("Update failed: Cannot create temporary directory")?;
189
190    let new_binary = if asset.name.ends_with(".tar.gz") {
191        archive::extract_targz(&archive_path, &extract_dir)
192            .context("Update failed: Cannot extract archive")?
193    } else if asset.name.ends_with(".zip") {
194        archive::extract_zip(&archive_path, &extract_dir)
195            .context("Update failed: Cannot extract archive")?
196    } else {
197        anyhow::bail!("Update failed: Unsupported archive format");
198    };
199
200    // Perform platform-specific update
201    #[cfg(unix)]
202    platform::perform_update_unix(&current_exe, &new_binary)
203        .context("Update failed: Cannot replace binary")?;
204
205    #[cfg(windows)]
206    platform::perform_update_windows(&current_exe, &new_binary)
207        .context("Update failed: Cannot replace binary")?;
208
209    // Clean up
210    let _ = fs::remove_file(&archive_path);
211    let _ = fs::remove_dir_all(&extract_dir);
212
213    // Display success message
214    println!(
215        "Upgraded from v{} to v{}",
216        extract_semver_version(current_version),
217        latest_version
218    );
219    println!(
220        "https://github.com/Mai0313/VibeCodingTracker/releases/tag/{}",
221        release.tag_name
222    );
223    println!(
224        "If you like this tool, please star us on GitHub: https://github.com/Mai0313/VibeCodingTracker"
225    );
226
227    Ok(())
228}
229
230/// Installs the latest release, but only if it is newer than the current one.
231///
232/// Returns `Ok(())` without doing anything when already up to date. Works
233/// regardless of how the binary was installed (npm/pip/cargo/manual), because
234/// every channel ships the same pre-compiled GitHub release binaries.
235///
236/// # Errors
237///
238/// Returns an error if the version comparison fails (GitHub fetch or version
239/// parse) or if the subsequent install fails (see `perform_installation`).
240pub fn perform_update() -> Result<()> {
241    // Get version comparison
242    let Some((current_version, _, latest_version, release)) = get_version_comparison()? else {
243        // Already on latest version
244        return Ok(());
245    };
246
247    perform_installation(&current_version, &latest_version, &release)
248}
249
250/// Installs the latest release unconditionally, skipping the freshness check.
251///
252/// Always re-downloads and reinstalls the latest tag even when the current
253/// binary already matches it (useful for repairing a broken install).
254///
255/// # Errors
256///
257/// Returns an error if the GitHub release fetch fails, if the current or
258/// latest version cannot be parsed, or if the install fails (see
259/// `perform_installation` — notably when no asset matches this platform).
260pub fn perform_force_update() -> Result<()> {
261    let release =
262        github::fetch_latest_release().context("Failed to fetch latest release information")?;
263
264    let (current_version_display, _) = get_current_version()?;
265
266    // Remove 'v' prefix if present and parse as semver
267    let latest_version_str = release.tag_name.trim_start_matches('v');
268    let latest_version = Version::parse(latest_version_str).context(format!(
269        "Failed to parse latest version: {}",
270        latest_version_str
271    ))?;
272
273    let _ = version_cache::record_version_check(&latest_version.to_string());
274
275    perform_installation(&current_version_display, &latest_version, &release)
276}
277
278/// Runs the `vct update` flow, optionally prompting for confirmation.
279///
280/// With `force` set, skips the freshness check and the prompt and reinstalls
281/// the latest release outright. Otherwise it checks for a newer version and,
282/// only if one exists, asks for `y`/`N` confirmation on stdin before
283/// installing — anything other than `y` cancels.
284///
285/// # Errors
286///
287/// Returns an error if the update check or install fails (network, version
288/// parse, asset selection, extraction, or binary swap), or if reading the
289/// confirmation from stdin fails.
290pub fn update_interactive(force: bool) -> Result<()> {
291    println!("Checking for updates...");
292
293    if force {
294        // Force update: skip version check, always download latest
295        perform_force_update()
296    } else {
297        // Normal update: check version and prompt for confirmation
298        if check_update()?.is_some() {
299            print!("Continue? (y/N): ");
300            std::io::Write::flush(&mut std::io::stdout())?;
301
302            let mut input = String::new();
303            std::io::stdin().read_line(&mut input)?;
304
305            if !input.trim().eq_ignore_ascii_case("y") {
306                println!("Cancelled");
307                return Ok(());
308            }
309            perform_update()
310        } else {
311            Ok(())
312        }
313    }
314}
315
316// Re-export functions for testing
317#[doc(hidden)]
318pub use archive::{extract_targz, extract_zip};
319#[doc(hidden)]
320pub use platform::get_asset_pattern;
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_extract_semver_version_clean() {
328        // Test extracting clean semver version
329        let version = "0.1.6";
330        assert_eq!(extract_semver_version(version), "0.1.6");
331    }
332
333    #[test]
334    fn test_extract_semver_version_with_git_metadata() {
335        // Test extracting version with git metadata
336        let version = "0.1.6-5-g1234567";
337        assert_eq!(extract_semver_version(version), "0.1.6");
338    }
339
340    #[test]
341    fn test_extract_semver_version_with_dirty_flag() {
342        // Test extracting version with dirty flag
343        let version = "0.1.6-5-g1234567-dirty";
344        assert_eq!(extract_semver_version(version), "0.1.6");
345    }
346
347    #[test]
348    fn test_extract_semver_version_rc() {
349        // Test extracting release candidate version
350        let version = "1.0.0-rc.1";
351        assert_eq!(extract_semver_version(version), "1.0.0");
352    }
353
354    #[test]
355    fn test_extract_semver_version_beta() {
356        // Test extracting beta version
357        let version = "2.3.4-beta.2";
358        assert_eq!(extract_semver_version(version), "2.3.4");
359    }
360
361    #[test]
362    fn test_extract_semver_version_alpha() {
363        // Test extracting alpha version
364        let version = "0.5.0-alpha";
365        assert_eq!(extract_semver_version(version), "0.5.0");
366    }
367
368    #[test]
369    fn test_extract_semver_version_complex() {
370        // Test extracting complex version string
371        let version = "1.2.3-45-gabcdef0-modified";
372        assert_eq!(extract_semver_version(version), "1.2.3");
373    }
374
375    #[test]
376    fn test_extract_semver_version_single_digit() {
377        // Test single digit versions
378        assert_eq!(extract_semver_version("1.0.0"), "1.0.0");
379        assert_eq!(extract_semver_version("0.0.1"), "0.0.1");
380    }
381
382    #[test]
383    fn test_extract_semver_version_large_numbers() {
384        // Test with large version numbers
385        assert_eq!(extract_semver_version("10.20.30"), "10.20.30");
386        assert_eq!(extract_semver_version("100.200.300-1-g123"), "100.200.300");
387    }
388
389    #[test]
390    fn test_extract_semver_version_empty() {
391        // Test with empty string (edge case)
392        assert_eq!(extract_semver_version(""), "");
393    }
394
395    #[test]
396    fn test_extract_semver_version_no_dashes() {
397        // Test version without any dashes
398        let version = "2.4.8";
399        assert_eq!(extract_semver_version(version), "2.4.8");
400    }
401
402    #[test]
403    fn test_extract_semver_version_multiple_dashes() {
404        // Test with multiple dashes
405        let version = "1.0.0-pre-release-candidate";
406        assert_eq!(extract_semver_version(version), "1.0.0");
407    }
408
409    #[test]
410    fn test_extract_semver_version_only_major_minor() {
411        // Test incomplete version (not standard semver, but should handle gracefully)
412        let version = "1.2";
413        assert_eq!(extract_semver_version(version), "1.2");
414    }
415
416    #[test]
417    fn test_extract_semver_version_with_v_prefix() {
418        // Test with v prefix (common in git tags)
419        // Note: This function doesn't strip 'v', that's done elsewhere
420        let version = "v1.2.3-dirty";
421        assert_eq!(extract_semver_version(version), "v1.2.3");
422    }
423
424    #[test]
425    fn test_extract_semver_version_consistency() {
426        // Test that calling twice gives same result
427        let version = "3.1.4-15-g926535-dirty";
428        let result1 = extract_semver_version(version);
429        let result2 = extract_semver_version(version);
430        assert_eq!(result1, result2);
431    }
432
433    #[test]
434    fn test_extract_semver_version_zero_version() {
435        // Test zero versions
436        assert_eq!(extract_semver_version("0.0.0"), "0.0.0");
437        assert_eq!(extract_semver_version("0.0.0-dev"), "0.0.0");
438    }
439
440    #[test]
441    fn test_extract_semver_version_patch_zero() {
442        // Test with patch version zero
443        assert_eq!(extract_semver_version("1.5.0"), "1.5.0");
444        assert_eq!(extract_semver_version("2.0.0-rc1"), "2.0.0");
445    }
446}