vtcode-core 0.175.6

Core library for VT Code - a Rust-based terminal coding agent
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};

use super::query::is_session_file;
use super::settings::{SESSION_MAX_AGE_DAYS_ENV, SESSION_MAX_FILES_ENV, SESSION_MAX_SIZE_MB_ENV, read_env_var};
use super::{BYTES_PER_MB, DEFAULT_SESSION_MAX_AGE_DAYS, DEFAULT_SESSION_MAX_FILES, DEFAULT_SESSION_MAX_SIZE_MB};
use crate::core::SECONDS_PER_DAY;

#[derive(Debug, Clone)]
struct SessionFileEntry {
    path: PathBuf,
    modified: SystemTime,
    size: u64,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct SessionRetentionLimits {
    pub(crate) max_files: usize,
    pub(crate) max_age_days: u64,
    pub(crate) max_total_size_bytes: u64,
}

impl Default for SessionRetentionLimits {
    fn default() -> Self {
        Self {
            max_files: DEFAULT_SESSION_MAX_FILES,
            max_age_days: DEFAULT_SESSION_MAX_AGE_DAYS,
            max_total_size_bytes: DEFAULT_SESSION_MAX_SIZE_MB.saturating_mul(BYTES_PER_MB),
        }
    }
}

impl SessionRetentionLimits {
    fn from_env() -> Self {
        let defaults = Self::default();
        Self {
            max_files: parse_env_value(SESSION_MAX_FILES_ENV).unwrap_or(defaults.max_files),
            max_age_days: parse_env_value(SESSION_MAX_AGE_DAYS_ENV).unwrap_or(defaults.max_age_days),
            max_total_size_bytes: parse_env_value::<u64>(SESSION_MAX_SIZE_MB_ENV)
                .map(|value| value.saturating_mul(BYTES_PER_MB))
                .unwrap_or(defaults.max_total_size_bytes),
        }
    }
}

fn session_retention_limits() -> SessionRetentionLimits {
    SessionRetentionLimits::from_env()
}

fn parse_env_value<T>(key: &str) -> Option<T>
where
    T: FromStr,
{
    read_env_var(key)?.trim().parse().ok()
}

pub(crate) fn apply_session_retention_best_effort(sessions_dir: &Path) {
    if let Err(err) = apply_session_retention(sessions_dir) {
        tracing::warn!(
            sessions_dir = %sessions_dir.display(),
            error = %err,
            "Failed to prune session archives"
        );
    }
}

fn apply_session_retention(sessions_dir: &Path) -> Result<()> {
    apply_session_retention_with_limits(sessions_dir, session_retention_limits())
}

pub(crate) fn apply_session_retention_with_limits(sessions_dir: &Path, limits: SessionRetentionLimits) -> Result<()> {
    let mut entries = collect_session_entries(sessions_dir)?;

    if entries.is_empty() {
        return Ok(());
    }

    let now = SystemTime::now();
    let age_cutoff = if limits.max_age_days == 0 {
        now
    } else {
        now.checked_sub(Duration::from_secs(limits.max_age_days.saturating_mul(SECONDS_PER_DAY)))
            .unwrap_or(UNIX_EPOCH)
    };

    let (expired, retained): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.modified <= age_cutoff);
    remove_session_files(expired);
    entries = retained;

    entries.sort_by_key(|a| std::cmp::Reverse(a.modified));

    if limits.max_files > 0 && entries.len() > limits.max_files {
        let overflow = entries.split_off(limits.max_files);
        remove_session_files(overflow);
    }

    if limits.max_total_size_bytes == 0 || entries.is_empty() {
        return Ok(());
    }

    let mut total_size = 0u64;
    let mut keep_entries = Vec::with_capacity(entries.len());
    let mut size_overflow = Vec::new();

    for entry in entries {
        let projected = total_size.saturating_add(entry.size);
        if keep_entries.is_empty() || projected <= limits.max_total_size_bytes {
            total_size = projected;
            keep_entries.push(entry);
        } else {
            size_overflow.push(entry);
        }
    }

    remove_session_files(size_overflow);
    Ok(())
}

fn collect_session_entries(sessions_dir: &Path) -> Result<Vec<SessionFileEntry>> {
    if !sessions_dir.exists() {
        return Ok(Vec::new());
    }

    let mut entries = Vec::new();
    for entry in fs::read_dir(sessions_dir)
        .with_context(|| format!("failed to read session directory for retention: {}", sessions_dir.display()))?
    {
        let entry = match entry {
            Ok(value) => value,
            Err(err) => {
                tracing::warn!(
                    sessions_dir = %sessions_dir.display(),
                    error = %err,
                    "Failed to read a session archive entry"
                );
                continue;
            }
        };
        let path = entry.path();
        if !is_session_file(&path) {
            continue;
        }
        let metadata = match fs::symlink_metadata(&path) {
            Ok(value) => value,
            Err(err) => {
                tracing::warn!(
                    path = %path.display(),
                    error = %err,
                    "Failed to read session archive metadata"
                );
                continue;
            }
        };
        if !metadata.is_file() {
            continue;
        }
        let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
        entries.push(SessionFileEntry { path, modified, size: metadata.len() });
    }

    Ok(entries)
}

fn remove_session_files(entries: Vec<SessionFileEntry>) {
    for entry in entries {
        if let Err(err) = fs::remove_file(&entry.path) {
            tracing::warn!(
                path = %entry.path.display(),
                error = %err,
                "Failed to remove session archive"
            );
        }
    }
}