use crate::utils::file_utils::{
ensure_dir_exists, ensure_dir_exists_sync, read_file_with_context, read_file_with_context_sync,
write_file_with_context, write_file_with_context_sync,
};
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use vtcode_exec_events::trace::{AGENT_TRACE_VERSION, TraceRecord};
pub const TRACES_DIR: &str = "traces";
#[derive(Debug, Clone)]
pub struct TraceStore {
base_dir: PathBuf,
}
impl TraceStore {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
pub fn for_workspace(workspace_path: impl AsRef<Path>) -> Self {
let base_dir = workspace_path.as_ref().join(".vtcode").join(TRACES_DIR);
Self::new(base_dir)
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
pub fn ensure_dir(&self) -> Result<()> {
ensure_dir_exists_sync(&self.base_dir)
.with_context(|| format!("Failed to create trace directory: {:?}", self.base_dir))?;
Ok(())
}
pub fn write_trace(&self, trace: &TraceRecord) -> Result<PathBuf> {
self.ensure_dir()?;
let filename = self.trace_filename(trace);
let path = self.base_dir.join(&filename);
let json = serde_json::to_string_pretty(trace)
.with_context(|| "Failed to serialize trace record")?;
write_file_with_context_sync(&path, &json, "trace record")
.with_context(|| format!("Failed to write trace to {:?}", path))?;
Ok(path)
}
pub fn read_trace(&self, filename: &str) -> Result<TraceRecord> {
let path = self.base_dir.join(filename);
self.read_trace_from_path(&path)
}
pub fn read_trace_from_path(&self, path: &Path) -> Result<TraceRecord> {
let content = read_file_with_context_sync(path, "trace record")
.with_context(|| format!("Failed to read trace: {:?}", path))?;
let trace: TraceRecord = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse trace: {:?}", path))?;
Ok(trace)
}
pub fn read_by_revision(&self, revision: &str) -> Result<Option<TraceRecord>> {
let short_rev = &revision[..revision.len().min(12)];
let filename = format!("{}.json", short_rev);
let path = self.base_dir.join(&filename);
if path.exists() {
Ok(Some(self.read_trace_from_path(&path)?))
} else {
let filename = format!("{}.json", revision);
let path = self.base_dir.join(&filename);
if path.exists() {
Ok(Some(self.read_trace_from_path(&path)?))
} else {
Ok(None)
}
}
}
pub fn list_traces(&self) -> Result<Vec<PathBuf>> {
if !self.base_dir.exists() {
return Ok(Vec::new());
}
let mut traces = Vec::new();
for entry in fs::read_dir(&self.base_dir)
.with_context(|| format!("Failed to read trace directory: {:?}", self.base_dir))?
{
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
traces.push(path);
}
}
traces.sort_by(|a, b| {
let a_time = fs::metadata(a).and_then(|m| m.modified()).ok();
let b_time = fs::metadata(b).and_then(|m| m.modified()).ok();
b_time.cmp(&a_time)
});
Ok(traces)
}
pub fn delete_trace(&self, filename: &str) -> Result<()> {
let path = self.base_dir.join(filename);
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to delete trace: {:?}", path))?;
}
Ok(())
}
pub fn cleanup(&self, keep_count: usize) -> Result<usize> {
let traces = self.list_traces()?;
let to_delete = traces.into_iter().skip(keep_count);
let mut deleted = 0;
for path in to_delete {
if let Err(e) = fs::remove_file(&path) {
tracing::warn!("Failed to delete old trace {:?}: {}", path, e);
} else {
deleted += 1;
}
}
Ok(deleted)
}
fn trace_filename(&self, trace: &TraceRecord) -> String {
if let Some(vcs) = &trace.vcs {
let short_rev = &vcs.revision[..vcs.revision.len().min(12)];
format!("{}.json", short_rev)
} else {
format!("{}.json", trace.id)
}
}
pub async fn ensure_dir_async(&self) -> Result<()> {
ensure_dir_exists(&self.base_dir)
.await
.with_context(|| format!("Failed to create trace directory: {:?}", self.base_dir))?;
Ok(())
}
pub async fn write_trace_async(&self, trace: &TraceRecord) -> Result<PathBuf> {
self.ensure_dir_async().await?;
let filename = self.trace_filename(trace);
let path = self.base_dir.join(&filename);
let json = serde_json::to_string_pretty(trace)
.with_context(|| "Failed to serialize trace record")?;
write_file_with_context(&path, &json, "trace record")
.await
.with_context(|| format!("Failed to write trace to {:?}", path))?;
Ok(path)
}
pub async fn read_trace_from_path_async(&self, path: &Path) -> Result<TraceRecord> {
let content = read_file_with_context(path, "trace record")
.await
.with_context(|| format!("Failed to read trace: {:?}", path))?;
let trace: TraceRecord = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse trace: {:?}", path))?;
Ok(trace)
}
pub async fn read_by_revision_async(&self, revision: &str) -> Result<Option<TraceRecord>> {
let short_rev = &revision[..revision.len().min(12)];
let filename = format!("{}.json", short_rev);
let path = self.base_dir.join(&filename);
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
Ok(Some(self.read_trace_from_path_async(&path).await?))
} else {
let filename = format!("{}.json", revision);
let path = self.base_dir.join(&filename);
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
Ok(Some(self.read_trace_from_path_async(&path).await?))
} else {
Ok(None)
}
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct TraceIndex {
pub version: String,
pub files: hashbrown::HashMap<String, Vec<String>>,
}
impl TraceIndex {
pub fn new() -> Self {
Self {
version: AGENT_TRACE_VERSION.to_string(),
files: hashbrown::HashMap::new(),
}
}
pub fn add_trace(&mut self, trace: &TraceRecord, filename: &str) {
for file in &trace.files {
self.files
.entry(file.path.clone())
.or_default()
.push(filename.to_string());
}
}
pub fn get_traces_for_file(&self, path: &str) -> Option<&Vec<String>> {
self.files.get(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use vtcode_exec_events::trace::{TraceFile, TraceRange, TraceRecordBuilder};
fn create_test_trace() -> TraceRecord {
TraceRecordBuilder::new()
.git_revision("abc123def456789012345678901234567890abcd")
.file(TraceFile::with_ai_ranges(
"src/main.rs",
"anthropic/claude-opus-4",
vec![TraceRange::new(1, 50)],
))
.build()
}
#[test]
fn test_trace_store_write_read() -> Result<()> {
let temp_dir = TempDir::new()?;
let store = TraceStore::new(temp_dir.path().join("traces"));
let trace = create_test_trace();
let path = store.write_trace(&trace)?;
assert!(path.exists());
let loaded = store.read_trace_from_path(&path)?;
assert_eq!(loaded.id, trace.id);
assert_eq!(loaded.files.len(), 1);
Ok(())
}
#[test]
fn test_trace_store_read_by_revision() -> Result<()> {
let temp_dir = TempDir::new()?;
let store = TraceStore::new(temp_dir.path().join("traces"));
let trace = create_test_trace();
store.write_trace(&trace)?;
let loaded = store.read_by_revision("abc123def456789012345678901234567890abcd")?;
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().id, trace.id);
Ok(())
}
#[test]
fn test_trace_store_list() -> Result<()> {
let temp_dir = TempDir::new()?;
let store = TraceStore::new(temp_dir.path().join("traces"));
let revisions = [
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1",
"c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1b2",
];
for rev in &revisions {
let trace = TraceRecordBuilder::new().git_revision(*rev).build();
store.write_trace(&trace)?;
}
let traces = store.list_traces()?;
assert_eq!(traces.len(), 3);
Ok(())
}
#[test]
fn test_trace_store_cleanup() -> Result<()> {
let temp_dir = TempDir::new()?;
let store = TraceStore::new(temp_dir.path().join("traces"));
let revisions = [
"1111111111111111111111111111111111111111",
"2222222222222222222222222222222222222222",
"3333333333333333333333333333333333333333",
"4444444444444444444444444444444444444444",
"5555555555555555555555555555555555555555",
];
for rev in &revisions {
let trace = TraceRecordBuilder::new().git_revision(*rev).build();
store.write_trace(&trace)?;
std::thread::sleep(std::time::Duration::from_millis(10));
}
let deleted = store.cleanup(2)?;
assert_eq!(deleted, 3);
let remaining = store.list_traces()?;
assert_eq!(remaining.len(), 2);
Ok(())
}
#[test]
fn test_trace_index() {
let mut index = TraceIndex::new();
let trace = create_test_trace();
index.add_trace(&trace, "abc123def456.json");
let traces = index.get_traces_for_file("src/main.rs");
assert!(traces.is_some());
assert_eq!(traces.unwrap().len(), 1);
}
#[tokio::test]
async fn test_trace_store_async() -> Result<()> {
let temp_dir = TempDir::new()?;
let store = TraceStore::new(temp_dir.path().join("traces"));
let trace = create_test_trace();
let path = store.write_trace_async(&trace).await?;
assert!(path.exists());
let loaded = store.read_trace_from_path_async(&path).await?;
assert_eq!(loaded.id, trace.id);
assert_eq!(loaded.files.len(), 1);
let loaded_by_rev = store
.read_by_revision_async("abc123def456789012345678901234567890abcd")
.await?;
assert!(loaded_by_rev.is_some());
Ok(())
}
}