vtcode_core/trace/
generator.rs1use crate::tools::handlers::turn_diff_tracker::{FileChange, FileChangeKind, TurnDiffTracker};
4use hashbrown::HashMap;
5use std::path::{Path, PathBuf};
6use vtcode_exec_events::trace::{
7 Contributor, RelatedResource, TraceConversation, TraceFile, TraceMetadata, TraceRange, TraceRecord,
8 TraceRecordBuilder, VtCodeMetadata, compute_content_hash, normalize_model_id,
9};
10
11pub use vtcode_exec_events::trace::TraceContext;
13
14pub struct TraceGenerator;
16
17impl TraceGenerator {
18 pub fn from_diff_tracker(tracker: &TurnDiffTracker, ctx: &TraceContext) -> Option<TraceRecord> {
20 if !tracker.has_changes() {
21 return None;
22 }
23
24 let mut builder = TraceRecordBuilder::new();
25
26 if let Some(ref revision) = ctx.revision {
28 builder = builder.git_revision(revision);
29 }
30
31 let workspace_path = ctx.workspace_path.clone().unwrap_or_else(|| PathBuf::from("."));
33
34 for (path, change) in tracker.changes() {
36 if let Some(trace_file) = Self::file_change_to_trace_file(path, change, ctx, &workspace_path) {
37 builder = builder.file(trace_file);
38 }
39 }
40
41 let metadata = TraceMetadata {
43 confidence: Some(1.0), vtcode: Some(VtCodeMetadata {
45 session_id: ctx.session_id.clone(),
46 turn_number: ctx.turn_number,
47 workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
48 provider: Some(ctx.provider.clone()),
49 }),
50 ..Default::default()
51 };
52 builder = builder.metadata(metadata);
53
54 let trace = builder.build();
55
56 if trace.has_attributions() { Some(trace) } else { None }
58 }
59
60 pub fn from_changes(changes: &HashMap<PathBuf, FileChange>, ctx: &TraceContext) -> Option<TraceRecord> {
62 if changes.is_empty() {
63 return None;
64 }
65
66 let mut builder = TraceRecordBuilder::new();
67
68 if let Some(ref revision) = ctx.revision {
69 builder = builder.git_revision(revision);
70 }
71
72 let workspace_path = ctx.workspace_path.clone().unwrap_or_else(|| PathBuf::from("."));
73
74 for (path, change) in changes {
75 if let Some(trace_file) = Self::file_change_to_trace_file(path, change, ctx, &workspace_path) {
76 builder = builder.file(trace_file);
77 }
78 }
79
80 let metadata = TraceMetadata {
81 confidence: Some(1.0),
82 vtcode: Some(VtCodeMetadata {
83 session_id: ctx.session_id.clone(),
84 turn_number: ctx.turn_number,
85 workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
86 provider: Some(ctx.provider.clone()),
87 }),
88 ..Default::default()
89 };
90 builder = builder.metadata(metadata);
91
92 let trace = builder.build();
93 if trace.has_attributions() { Some(trace) } else { None }
94 }
95
96 fn file_change_to_trace_file(
98 path: &Path,
99 change: &FileChange,
100 ctx: &TraceContext,
101 workspace_path: &Path,
102 ) -> Option<TraceFile> {
103 let relative_path = path.strip_prefix(workspace_path).unwrap_or(path).to_string_lossy().to_string();
105
106 let (line_range, content_for_hash) = match &change.kind {
108 FileChangeKind::Add { content } => {
109 let line_count = content.lines().count() as u32;
110 (Some((1, line_count.max(1))), Some(content.as_str()))
111 }
112 FileChangeKind::Update { new_content, .. } => {
113 if let Some((start, end)) = change.line_range {
115 (Some((start, end)), Some(new_content.as_str()))
116 } else {
117 let line_count = new_content.lines().count() as u32;
118 (Some((1, line_count.max(1))), Some(new_content.as_str()))
119 }
120 }
121 FileChangeKind::Delete { .. } => {
122 return None;
124 }
125 FileChangeKind::Rename { new_content, .. } => {
126 if let Some(content) = new_content {
127 let line_count = content.lines().count() as u32;
128 (Some((1, line_count.max(1))), Some(content.as_str()))
129 } else {
130 return None;
131 }
132 }
133 };
134
135 let (start_line, end_line) = line_range?;
136
137 let mut range = TraceRange::new(start_line, end_line);
139 if let Some(content) = content_for_hash {
140 range = range.with_hash(compute_content_hash(content));
141 }
142
143 let contributor = if let Some(ref attr) = change.attribution {
145 if let Some(model_id) = attr.normalized_model_id() {
146 Contributor::ai(model_id)
147 } else {
148 match attr.contributor_type.as_str() {
149 "human" => Contributor::human(),
150 "mixed" => Contributor::mixed(),
151 _ => Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider)),
152 }
153 }
154 } else {
155 Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider))
157 };
158
159 let mut conversation = TraceConversation {
161 url: None,
162 contributor: Some(contributor),
163 ranges: vec![range],
164 related: None,
165 };
166
167 if let Some(ref session_id) = ctx.session_id {
169 let session_url =
171 format!("file://{}/sessions/{}.json", workspace_path.join(".vtcode").display(), session_id);
172 conversation.url = Some(session_url.clone());
173 conversation.related = Some(vec![RelatedResource::session(session_url)]);
174 }
175
176 let mut trace_file = TraceFile::new(relative_path);
177 trace_file.add_conversation(conversation);
178
179 Some(trace_file)
180 }
181}
182
183pub fn get_git_head_revision(workspace_path: &Path) -> Option<String> {
191 let output = std::process::Command::new("git")
192 .args(["rev-parse", "HEAD"])
193 .current_dir(workspace_path)
194 .output()
195 .ok()?;
196
197 if output.status.success() {
198 let revision = String::from_utf8_lossy(&output.stdout).trim().to_string();
199 if revision.len() >= 40 { Some(revision) } else { None }
200 } else {
201 None
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::tools::handlers::turn_diff_tracker::ChangeAttribution;
209
210 #[test]
211 fn test_trace_from_diff_tracker() {
212 let mut tracker = TurnDiffTracker::new();
213 tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
214
215 let mut changes = HashMap::new();
216 changes.insert(
217 PathBuf::from("/workspace/src/main.rs"),
218 FileChange::add("fn main() {\n println!(\"Hello\");\n}"),
219 );
220 tracker.on_patch_begin(changes);
221 tracker.on_patch_end(true);
222
223 let ctx = TraceContext::new("claude-opus-4", "anthropic")
224 .with_workspace_path("/workspace")
225 .with_session_id("session-123")
226 .with_turn_number(1);
227
228 let trace = TraceGenerator::from_diff_tracker(&tracker, &ctx);
229 assert!(trace.is_some());
230
231 let trace = trace.unwrap();
232 assert_eq!(trace.files.len(), 1);
233 assert_eq!(trace.files[0].path, "src/main.rs");
234 assert_eq!(trace.files[0].conversations.len(), 1);
235 assert_eq!(trace.files[0].conversations[0].ranges.len(), 1);
236 }
237
238 #[test]
239 fn test_trace_with_git_revision() {
240 let ctx = TraceContext::new("gpt-5", "openai")
241 .with_workspace_path("/workspace")
242 .with_revision("abc123def456789012345678901234567890abcd");
243
244 let mut changes = HashMap::new();
245 changes.insert(PathBuf::from("/workspace/test.rs"), FileChange::add("test"));
246
247 let trace = TraceGenerator::from_changes(&changes, &ctx);
248 assert!(trace.is_some());
249
250 let trace = trace.unwrap();
251 assert!(trace.vcs.is_some());
252 assert_eq!(trace.vcs.unwrap().revision, "abc123def456789012345678901234567890abcd");
253 }
254
255 #[test]
256 fn test_trace_empty_changes() {
257 let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
258 let changes = HashMap::new();
259
260 let trace = TraceGenerator::from_changes(&changes, &ctx);
261 assert!(trace.is_none());
262 }
263
264 #[test]
265 fn test_trace_delete_not_included() {
266 let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
267
268 let mut changes = HashMap::new();
269 changes.insert(PathBuf::from("/workspace/deleted.rs"), FileChange::delete("old content"));
270
271 let trace = TraceGenerator::from_changes(&changes, &ctx);
272 assert!(trace.is_none());
274 }
275}