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