vtcode 0.147.2

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
use anyhow::{Context, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[cfg(test)]
use std::sync::{LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use vtcode_commons::VtCodePaths;

#[cfg(test)]
static TEST_CACHE_DIR: LazyLock<Mutex<Option<PathBuf>>> = LazyLock::new(|| Mutex::new(None));

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct UpdateCacheSnapshot {
    pub(super) last_checked: Option<SystemTime>,
    pub(super) latest_version: Option<Version>,
    pub(super) latest_was_newer: bool,
    pub(super) last_seen_version: Option<Version>,
    pub(super) dismissed_version: Option<Version>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct UpdateCachePayload {
    last_checked_unix_secs: u64,
    #[serde(default)]
    latest_version: Option<String>,
    #[serde(default)]
    latest_was_newer: bool,
    #[serde(default)]
    last_seen_version: Option<String>,
    #[serde(default)]
    dismissed_version: Option<String>,
}

pub(super) fn read_snapshot() -> Result<UpdateCacheSnapshot> {
    let cache_file = cache_file_path()?;
    let metadata = match std::fs::symlink_metadata(&cache_file) {
        Ok(metadata) if metadata.file_type().is_symlink() => {
            anyhow::bail!("refusing to read symlinked update cache {}", cache_file.display())
        }
        Ok(metadata) if !metadata.is_file() => {
            anyhow::bail!("update cache is not a regular file: {}", cache_file.display())
        }
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(UpdateCacheSnapshot::default());
        }
        Err(error) => {
            return Err(error).with_context(|| format!("Failed to inspect update cache {}", cache_file.display()));
        }
    };
    let modified = metadata.modified().ok();

    let content = String::from_utf8(VtCodePaths::read_file_no_follow(&cache_file)?)
        .with_context(|| format!("Failed to read update cache metadata {}", cache_file.display()))?;

    let trimmed = content.trim();
    if trimmed.is_empty() {
        return Ok(UpdateCacheSnapshot {
            last_checked: modified,
            latest_version: None,
            latest_was_newer: false,
            last_seen_version: None,
            dismissed_version: None,
        });
    }

    let Ok(payload) = serde_json::from_str::<UpdateCachePayload>(trimmed) else {
        return Ok(UpdateCacheSnapshot {
            last_checked: modified,
            latest_version: None,
            latest_was_newer: false,
            last_seen_version: None,
            dismissed_version: None,
        });
    };

    Ok(UpdateCacheSnapshot {
        last_checked: Some(UNIX_EPOCH + std::time::Duration::from_secs(payload.last_checked_unix_secs)).or(modified),
        latest_version: payload.latest_version.as_deref().and_then(|value| Version::parse(value).ok()),
        latest_was_newer: payload.latest_was_newer,
        last_seen_version: payload
            .last_seen_version
            .as_deref()
            .and_then(|value| Version::parse(value).ok()),
        dismissed_version: payload
            .dismissed_version
            .as_deref()
            .and_then(|value| Version::parse(value).ok()),
    })
}

pub(super) fn record_successful_check(latest_version: Option<&Version>, latest_was_newer: bool) -> Result<()> {
    let existing = read_snapshot().unwrap_or_default();
    write_snapshot(UpdateCacheSnapshot {
        last_checked: Some(SystemTime::now()),
        latest_version: latest_version.cloned(),
        latest_was_newer,
        last_seen_version: existing.last_seen_version,
        dismissed_version: existing.dismissed_version,
    })
}

pub(super) fn record_failed_check() -> Result<()> {
    let mut snapshot = read_snapshot()?;
    snapshot.last_checked = Some(SystemTime::now());
    write_snapshot(snapshot)
}

pub(super) fn record_seen_version(version: &Version) -> Result<()> {
    let mut snapshot = read_snapshot()?;
    snapshot.last_seen_version = Some(version.clone());
    write_snapshot(snapshot)
}

pub(super) fn record_dismissed_version(version: &Version) -> Result<()> {
    let mut snapshot = read_snapshot()?;
    snapshot.dismissed_version = Some(version.clone());
    write_snapshot(snapshot)
}

pub(super) fn clear_dismissed_version() -> Result<()> {
    let mut snapshot = read_snapshot()?;
    snapshot.dismissed_version = None;
    write_snapshot(snapshot)
}

fn write_snapshot(snapshot: UpdateCacheSnapshot) -> Result<()> {
    let last_checked = snapshot.last_checked.unwrap_or_else(SystemTime::now);
    let last_checked_unix_secs = last_checked.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
    let payload = UpdateCachePayload {
        last_checked_unix_secs,
        latest_version: snapshot.latest_version.map(|version| version.to_string()),
        latest_was_newer: snapshot.latest_was_newer,
        last_seen_version: snapshot.last_seen_version.map(|version| version.to_string()),
        dismissed_version: snapshot.dismissed_version.map(|version| version.to_string()),
    };
    let serialized = serde_json::to_string(&payload).context("Failed to serialize update cache payload")?;
    let cache_file = cache_file_path()?;
    if let Some(parent) = cache_file.parent() {
        VtCodePaths::ensure_user_dir(parent).context("Failed to create update cache directory")?;
    }
    VtCodePaths::write_private_file_atomic(&cache_file, serialized.as_bytes())
        .with_context(|| format!("Failed to write update cache {}", cache_file.display()))?;
    Ok(())
}

fn get_cache_dir() -> Result<PathBuf> {
    #[cfg(test)]
    if let Some(path) = TEST_CACHE_DIR.lock().unwrap_or_else(|error| error.into_inner()).clone() {
        return VtCodePaths::ensure_user_dir(path);
    }

    Ok(VtCodePaths::resolve()?.ensure_cache_dir()?.to_path_buf())
}

#[cfg(test)]
pub(super) fn set_cache_dir_override_for_tests(path: Option<PathBuf>) -> Option<PathBuf> {
    let mut guard = TEST_CACHE_DIR.lock().unwrap_or_else(|error| error.into_inner());
    std::mem::replace(&mut *guard, path)
}

fn cache_file_path() -> Result<PathBuf> {
    Ok(get_cache_dir()?.join("last_update_check"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn empty_legacy_cache_file_uses_file_metadata() {
        let temp_dir = TempDir::new().expect("temp dir");
        let previous = set_cache_dir_override_for_tests(Some(temp_dir.path().to_path_buf()));

        let cache_file = cache_file_path().expect("cache path");
        std::fs::write(&cache_file, "").expect("write legacy cache");

        let snapshot = read_snapshot().expect("read snapshot");
        assert!(snapshot.last_checked.is_some());
        assert!(snapshot.latest_version.is_none());
        assert!(!snapshot.latest_was_newer);

        set_cache_dir_override_for_tests(previous);
    }

    #[test]
    fn json_cache_round_trips_latest_version() {
        let temp_dir = TempDir::new().expect("temp dir");
        let previous = set_cache_dir_override_for_tests(Some(temp_dir.path().to_path_buf()));

        let version = Version::parse("0.113.0").expect("version");
        record_successful_check(Some(&version), true).expect("write cache");

        let snapshot = read_snapshot().expect("read snapshot");
        assert_eq!(snapshot.latest_version, Some(version));
        assert!(snapshot.latest_was_newer);
        assert!(snapshot.last_checked.is_some());
        assert!(snapshot.dismissed_version.is_none());

        set_cache_dir_override_for_tests(previous);
    }

    #[test]
    fn record_and_clear_dismissed_version() {
        let temp_dir = TempDir::new().expect("temp dir");
        let previous = set_cache_dir_override_for_tests(Some(temp_dir.path().to_path_buf()));

        let version = Version::parse("0.113.0").expect("version");
        record_successful_check(Some(&version), true).expect("write cache");
        assert!(read_snapshot().expect("snapshot").dismissed_version.is_none());

        record_dismissed_version(&version).expect("record dismissal");
        let snapshot = read_snapshot().expect("read snapshot");
        assert_eq!(snapshot.dismissed_version, Some(version));

        clear_dismissed_version().expect("clear dismissal");
        assert!(read_snapshot().expect("snapshot").dismissed_version.is_none());

        set_cache_dir_override_for_tests(previous);
    }
}