use crate::tools::handlers::turn_diff_tracker::{FileChange, FileChangeKind, TurnDiffTracker};
use hashbrown::HashMap;
use std::path::{Path, PathBuf};
use vtcode_exec_events::trace::{
Contributor, RelatedResource, TraceConversation, TraceFile, TraceMetadata, TraceRange,
TraceRecord, TraceRecordBuilder, VtCodeMetadata, compute_content_hash, normalize_model_id,
};
pub use vtcode_exec_events::trace::TraceContext;
pub struct TraceGenerator;
impl TraceGenerator {
pub fn from_diff_tracker(tracker: &TurnDiffTracker, ctx: &TraceContext) -> Option<TraceRecord> {
if !tracker.has_changes() {
return None;
}
let mut builder = TraceRecordBuilder::new();
if let Some(ref revision) = ctx.revision {
builder = builder.git_revision(revision);
}
let workspace_path = ctx
.workspace_path
.clone()
.unwrap_or_else(|| PathBuf::from("."));
for (path, change) in tracker.changes() {
if let Some(trace_file) =
Self::file_change_to_trace_file(path, change, ctx, &workspace_path)
{
builder = builder.file(trace_file);
}
}
let metadata = TraceMetadata {
confidence: Some(1.0), vtcode: Some(VtCodeMetadata {
session_id: ctx.session_id.clone(),
turn_number: ctx.turn_number,
workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
provider: Some(ctx.provider.clone()),
}),
..Default::default()
};
builder = builder.metadata(metadata);
let trace = builder.build();
if trace.has_attributions() {
Some(trace)
} else {
None
}
}
pub fn from_changes(
changes: &HashMap<PathBuf, FileChange>,
ctx: &TraceContext,
) -> Option<TraceRecord> {
if changes.is_empty() {
return None;
}
let mut builder = TraceRecordBuilder::new();
if let Some(ref revision) = ctx.revision {
builder = builder.git_revision(revision);
}
let workspace_path = ctx
.workspace_path
.clone()
.unwrap_or_else(|| PathBuf::from("."));
for (path, change) in changes {
if let Some(trace_file) =
Self::file_change_to_trace_file(path, change, ctx, &workspace_path)
{
builder = builder.file(trace_file);
}
}
let metadata = TraceMetadata {
confidence: Some(1.0),
vtcode: Some(VtCodeMetadata {
session_id: ctx.session_id.clone(),
turn_number: ctx.turn_number,
workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
provider: Some(ctx.provider.clone()),
}),
..Default::default()
};
builder = builder.metadata(metadata);
let trace = builder.build();
if trace.has_attributions() {
Some(trace)
} else {
None
}
}
fn file_change_to_trace_file(
path: &Path,
change: &FileChange,
ctx: &TraceContext,
workspace_path: &Path,
) -> Option<TraceFile> {
let relative_path = path
.strip_prefix(workspace_path)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let (line_range, content_for_hash) = match &change.kind {
FileChangeKind::Add { content } => {
let line_count = content.lines().count() as u32;
(Some((1, line_count.max(1))), Some(content.as_str()))
}
FileChangeKind::Update { new_content, .. } => {
if let Some((start, end)) = change.line_range {
(Some((start, end)), Some(new_content.as_str()))
} else {
let line_count = new_content.lines().count() as u32;
(Some((1, line_count.max(1))), Some(new_content.as_str()))
}
}
FileChangeKind::Delete { .. } => {
return None;
}
FileChangeKind::Rename { new_content, .. } => {
if let Some(content) = new_content {
let line_count = content.lines().count() as u32;
(Some((1, line_count.max(1))), Some(content.as_str()))
} else {
return None;
}
}
};
let (start_line, end_line) = line_range?;
let mut range = TraceRange::new(start_line, end_line);
if let Some(content) = content_for_hash {
range = range.with_hash(compute_content_hash(content));
}
let contributor = if let Some(ref attr) = change.attribution {
if let Some(model_id) = attr.normalized_model_id() {
Contributor::ai(model_id)
} else {
match attr.contributor_type.as_str() {
"human" => Contributor::human(),
"mixed" => Contributor::mixed(),
_ => Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider)),
}
}
} else {
Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider))
};
let mut conversation = TraceConversation {
url: None,
contributor: Some(contributor),
ranges: vec![range],
related: None,
};
if let Some(ref session_id) = ctx.session_id {
let session_url = format!(
"file://{}/sessions/{}.json",
workspace_path.join(".vtcode").display(),
session_id
);
conversation.url = Some(session_url.clone());
conversation.related = Some(vec![RelatedResource::session(session_url)]);
}
let mut trace_file = TraceFile::new(relative_path);
trace_file.add_conversation(conversation);
Some(trace_file)
}
}
pub fn get_git_head_revision(workspace_path: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(workspace_path)
.output()
.ok()?;
if output.status.success() {
let revision = String::from_utf8_lossy(&output.stdout).trim().to_string();
if revision.len() >= 40 {
Some(revision)
} else {
None
}
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::handlers::turn_diff_tracker::ChangeAttribution;
#[test]
fn test_trace_from_diff_tracker() {
let mut tracker = TurnDiffTracker::new();
tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("/workspace/src/main.rs"),
FileChange::add("fn main() {\n println!(\"Hello\");\n}"),
);
tracker.on_patch_begin(changes);
tracker.on_patch_end(true);
let ctx = TraceContext::new("claude-opus-4", "anthropic")
.with_workspace_path("/workspace")
.with_session_id("session-123")
.with_turn_number(1);
let trace = TraceGenerator::from_diff_tracker(&tracker, &ctx);
assert!(trace.is_some());
let trace = trace.unwrap();
assert_eq!(trace.files.len(), 1);
assert_eq!(trace.files[0].path, "src/main.rs");
assert_eq!(trace.files[0].conversations.len(), 1);
assert_eq!(trace.files[0].conversations[0].ranges.len(), 1);
}
#[test]
fn test_trace_with_git_revision() {
let ctx = TraceContext::new("gpt-5", "openai")
.with_workspace_path("/workspace")
.with_revision("abc123def456789012345678901234567890abcd");
let mut changes = HashMap::new();
changes.insert(PathBuf::from("/workspace/test.rs"), FileChange::add("test"));
let trace = TraceGenerator::from_changes(&changes, &ctx);
assert!(trace.is_some());
let trace = trace.unwrap();
assert!(trace.vcs.is_some());
assert_eq!(
trace.vcs.unwrap().revision,
"abc123def456789012345678901234567890abcd"
);
}
#[test]
fn test_trace_empty_changes() {
let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
let changes = HashMap::new();
let trace = TraceGenerator::from_changes(&changes, &ctx);
assert!(trace.is_none());
}
#[test]
fn test_trace_delete_not_included() {
let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
let mut changes = HashMap::new();
changes.insert(
PathBuf::from("/workspace/deleted.rs"),
FileChange::delete("old content"),
);
let trace = TraceGenerator::from_changes(&changes, &ctx);
assert!(trace.is_none());
}
}