Skip to main content

vtcode_exec_events/
trace.rs

1//! Agent Trace specification types for AI code attribution.
2//!
3//! This module implements the [Agent Trace](https://agent-trace.dev/) specification v0.1.0,
4//! providing vendor-neutral types for recording AI contributions alongside human authorship
5//! in version-controlled codebases.
6//!
7//! # Overview
8//!
9//! Agent Trace defines how to track which code came from AI versus humans with:
10//! - Line-level granularity for attribution
11//! - Conversation linkage for provenance
12//! - VCS integration for revision tracking
13//! - Extensible metadata for vendor-specific data
14//!
15//! # Example
16//!
17//! ```rust
18//! use vtcode_exec_events::trace::*;
19//! use uuid::Uuid;
20//! use chrono::Utc;
21//!
22//! let trace = TraceRecord {
23//!     version: AGENT_TRACE_VERSION.to_string(),
24//!     id: Uuid::new_v4(),
25//!     timestamp: Utc::now(),
26//!     vcs: Some(VcsInfo {
27//!         vcs_type: VcsType::Git,
28//!         revision: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0".to_string(),
29//!     }),
30//!     tool: Some(ToolInfo {
31//!         name: "vtcode".to_string(),
32//!         version: Some(env!("CARGO_PKG_VERSION").to_string()),
33//!     }),
34//!     files: vec![],
35//!     metadata: None,
36//! };
37//! ```
38
39use chrono::{DateTime, Utc};
40use hashbrown::HashMap;
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43use std::path::PathBuf;
44
45/// Current Agent Trace specification version.
46pub const AGENT_TRACE_VERSION: &str = "0.1.0";
47
48/// MIME type for Agent Trace records.
49pub const AGENT_TRACE_MIME_TYPE: &str = "application/vnd.agent-trace.record+json";
50
51// ============================================================================
52// Core Types
53// ============================================================================
54
55/// A complete Agent Trace record tracking AI contributions to code.
56///
57/// This is the fundamental unit of Agent Trace - a snapshot of attribution
58/// data for files changed in a specific revision.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
61pub struct TraceRecord {
62    /// Agent Trace specification version (e.g., "0.1.0").
63    pub version: String,
64
65    /// Unique identifier for this trace record (UUID v4).
66    #[serde(
67        serialize_with = "serialize_uuid",
68        deserialize_with = "deserialize_uuid"
69    )]
70    #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
71    pub id: uuid::Uuid,
72
73    /// RFC 3339 timestamp when trace was recorded.
74    pub timestamp: DateTime<Utc>,
75
76    /// Version control system information for this trace.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub vcs: Option<VcsInfo>,
79
80    /// The tool that generated this trace.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub tool: Option<ToolInfo>,
83
84    /// Array of files with attributed ranges.
85    pub files: Vec<TraceFile>,
86
87    /// Additional metadata for implementation-specific or vendor-specific data.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub metadata: Option<TraceMetadata>,
90}
91
92impl TraceRecord {
93    /// Create a new trace record with required fields.
94    pub fn new() -> Self {
95        Self {
96            version: AGENT_TRACE_VERSION.to_string(),
97            id: uuid::Uuid::new_v4(),
98            timestamp: Utc::now(),
99            vcs: None,
100            tool: Some(ToolInfo::vtcode()),
101            files: Vec::new(),
102            metadata: None,
103        }
104    }
105
106    /// Create a trace record for a specific git revision.
107    pub fn for_git_revision(revision: impl Into<String>) -> Self {
108        let mut trace = Self::new();
109        trace.vcs = Some(VcsInfo::git(revision));
110        trace
111    }
112
113    /// Add a file to the trace record.
114    pub fn add_file(&mut self, file: TraceFile) {
115        self.files.push(file);
116    }
117
118    /// Check if the trace has any attributed ranges.
119    pub fn has_attributions(&self) -> bool {
120        self.files
121            .iter()
122            .any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
123    }
124}
125
126impl Default for TraceRecord {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132// ============================================================================
133// VCS Types
134// ============================================================================
135
136/// Version control system information.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
139pub struct VcsInfo {
140    /// Version control system type.
141    #[serde(rename = "type")]
142    pub vcs_type: VcsType,
143
144    /// Revision identifier (e.g., git commit SHA, jj change ID).
145    pub revision: String,
146}
147
148impl VcsInfo {
149    /// Create VCS info for a git repository.
150    pub fn git(revision: impl Into<String>) -> Self {
151        Self {
152            vcs_type: VcsType::Git,
153            revision: revision.into(),
154        }
155    }
156
157    /// Create VCS info for a Jujutsu repository.
158    pub fn jj(change_id: impl Into<String>) -> Self {
159        Self {
160            vcs_type: VcsType::Jj,
161            revision: change_id.into(),
162        }
163    }
164}
165
166/// Supported version control system types.
167#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
169#[serde(rename_all = "lowercase")]
170pub enum VcsType {
171    /// Git version control.
172    Git,
173    /// Jujutsu (jj) version control.
174    Jj,
175    /// Mercurial version control.
176    Hg,
177    /// Subversion.
178    Svn,
179}
180
181// ============================================================================
182// Tool Types
183// ============================================================================
184
185/// Information about the tool that generated the trace.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
188pub struct ToolInfo {
189    /// Name of the tool.
190    pub name: String,
191
192    /// Version of the tool.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub version: Option<String>,
195}
196
197impl ToolInfo {
198    /// Create tool info for VT Code.
199    pub fn vtcode() -> Self {
200        Self {
201            name: "vtcode".to_string(),
202            version: Some(env!("CARGO_PKG_VERSION").to_string()),
203        }
204    }
205
206    /// Create custom tool info.
207    pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
208        Self {
209            name: name.into(),
210            version,
211        }
212    }
213}
214
215// ============================================================================
216// File Attribution Types
217// ============================================================================
218
219/// A file with attributed conversation ranges.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
222pub struct TraceFile {
223    /// Relative file path from repository root.
224    pub path: String,
225
226    /// Array of conversations that contributed to this file.
227    pub conversations: Vec<TraceConversation>,
228}
229
230impl TraceFile {
231    /// Create a new trace file entry.
232    pub fn new(path: impl Into<String>) -> Self {
233        Self {
234            path: path.into(),
235            conversations: Vec::new(),
236        }
237    }
238
239    /// Add a conversation to the file.
240    pub fn add_conversation(&mut self, conversation: TraceConversation) {
241        self.conversations.push(conversation);
242    }
243
244    /// Create a file with a single AI-attributed conversation.
245    pub fn with_ai_ranges(
246        path: impl Into<String>,
247        model_id: impl Into<String>,
248        ranges: Vec<TraceRange>,
249    ) -> Self {
250        let mut file = Self::new(path);
251        file.add_conversation(TraceConversation {
252            url: None,
253            contributor: Some(Contributor::ai(model_id)),
254            ranges,
255            related: None,
256        });
257        file
258    }
259}
260
261/// A conversation that contributed code to a file.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
263#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
264pub struct TraceConversation {
265    /// URL to look up the conversation that produced this code.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub url: Option<String>,
268
269    /// The contributor for ranges in this conversation.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub contributor: Option<Contributor>,
272
273    /// Array of line ranges produced by this conversation.
274    pub ranges: Vec<TraceRange>,
275
276    /// Other related resources.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub related: Option<Vec<RelatedResource>>,
279}
280
281impl TraceConversation {
282    /// Create a conversation with AI contributor.
283    pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
284        Self {
285            url: None,
286            contributor: Some(Contributor::ai(model_id)),
287            ranges,
288            related: None,
289        }
290    }
291
292    /// Create a conversation with session URL.
293    pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
294        self.url = Some(url.into());
295        self
296    }
297}
298
299/// A related resource linked to a conversation.
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
302pub struct RelatedResource {
303    /// Type of the related resource.
304    #[serde(rename = "type")]
305    pub resource_type: String,
306
307    /// URL of the related resource.
308    pub url: String,
309}
310
311impl RelatedResource {
312    /// Create a session resource link.
313    pub fn session(url: impl Into<String>) -> Self {
314        Self {
315            resource_type: "session".to_string(),
316            url: url.into(),
317        }
318    }
319
320    /// Create a prompt resource link.
321    pub fn prompt(url: impl Into<String>) -> Self {
322        Self {
323            resource_type: "prompt".to_string(),
324            url: url.into(),
325        }
326    }
327}
328
329// ============================================================================
330// Range Attribution Types
331// ============================================================================
332
333/// A range of lines with attribution information.
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
336pub struct TraceRange {
337    /// Start line number (1-indexed, inclusive).
338    pub start_line: u32,
339
340    /// End line number (1-indexed, inclusive).
341    pub end_line: u32,
342
343    /// Hash of attributed content for position-independent tracking.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub content_hash: Option<String>,
346
347    /// Override contributor for this specific range (e.g., for agent handoffs).
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub contributor: Option<Contributor>,
350}
351
352impl TraceRange {
353    /// Create a new range.
354    pub fn new(start_line: u32, end_line: u32) -> Self {
355        Self {
356            start_line,
357            end_line,
358            content_hash: None,
359            contributor: None,
360        }
361    }
362
363    /// Create a range for a single line.
364    pub fn single_line(line: u32) -> Self {
365        Self::new(line, line)
366    }
367
368    /// Add a content hash to the range.
369    pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
370        self.content_hash = Some(hash.into());
371        self
372    }
373
374    /// Compute and set content hash from content using MurmurHash3.
375    pub fn with_content_hash(mut self, content: &str) -> Self {
376        let hash = compute_content_hash(content);
377        self.content_hash = Some(hash);
378        self
379    }
380}
381
382// ============================================================================
383// Contributor Types
384// ============================================================================
385
386/// The contributor that produced a code contribution.
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
388#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
389pub struct Contributor {
390    /// Type of contributor.
391    #[serde(rename = "type")]
392    pub contributor_type: ContributorType,
393
394    /// Model identifier following models.dev convention (e.g., "anthropic/claude-opus-4-5-20251101").
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub model_id: Option<String>,
397}
398
399impl Contributor {
400    /// Create an AI contributor with model ID.
401    pub fn ai(model_id: impl Into<String>) -> Self {
402        Self {
403            contributor_type: ContributorType::Ai,
404            model_id: Some(model_id.into()),
405        }
406    }
407
408    /// Create a human contributor.
409    pub fn human() -> Self {
410        Self {
411            contributor_type: ContributorType::Human,
412            model_id: None,
413        }
414    }
415
416    /// Create a mixed contributor (human-edited AI or AI-edited human).
417    pub fn mixed() -> Self {
418        Self {
419            contributor_type: ContributorType::Mixed,
420            model_id: None,
421        }
422    }
423
424    /// Create an unknown contributor.
425    pub fn unknown() -> Self {
426        Self {
427            contributor_type: ContributorType::Unknown,
428            model_id: None,
429        }
430    }
431}
432
433/// Type of contributor for code attribution.
434#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
435#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
436#[serde(rename_all = "lowercase")]
437pub enum ContributorType {
438    /// Code authored directly by a human developer.
439    Human,
440    /// Code generated by AI.
441    Ai,
442    /// Human-edited AI output or AI-edited human code.
443    Mixed,
444    /// Origin cannot be determined.
445    Unknown,
446}
447
448// ============================================================================
449// Metadata Types
450// ============================================================================
451
452/// Additional metadata for trace records.
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
454#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
455pub struct TraceMetadata {
456    /// Confidence score for the attribution (0.0 - 1.0).
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub confidence: Option<f64>,
459
460    /// Post-processing tools applied to the code.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub post_processing_tools: Option<Vec<String>>,
463
464    /// VT Code specific metadata.
465    #[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
466    pub vtcode: Option<VtCodeMetadata>,
467
468    /// Additional vendor-specific data.
469    #[serde(flatten)]
470    #[cfg_attr(feature = "schema-export", schemars(skip))]
471    pub extra: HashMap<String, Value>,
472}
473
474/// VT Code specific metadata in traces.
475#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
476#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
477pub struct VtCodeMetadata {
478    /// Session ID that produced this trace.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub session_id: Option<String>,
481
482    /// Turn number within the session.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub turn_number: Option<u32>,
485
486    /// Workspace path.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub workspace_path: Option<String>,
489
490    /// Provider name (anthropic, openai, etc.).
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub provider: Option<String>,
493}
494
495// ============================================================================
496// Helper Functions
497// ============================================================================
498
499/// Hash algorithm for content hashes.
500#[derive(Debug, Clone, Copy, Default)]
501pub enum HashAlgorithm {
502    /// MurmurHash3 (recommended by Agent Trace spec for cross-tool compatibility).
503    #[default]
504    MurmurHash3,
505    /// FNV-1a (simple and fast fallback).
506    Fnv1a,
507}
508
509/// Compute a content hash using the default algorithm (MurmurHash3).
510///
511/// MurmurHash3 is recommended by the Agent Trace spec for cross-tool compatibility.
512pub fn compute_content_hash(content: &str) -> String {
513    compute_content_hash_with(content, HashAlgorithm::default())
514}
515
516/// Compute a content hash using the specified algorithm.
517pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
518    match algorithm {
519        HashAlgorithm::MurmurHash3 => {
520            // MurmurHash3 x86_32 implementation
521            let hash = murmur3_32(content.as_bytes(), 0);
522            format!("murmur3:{hash:08x}")
523        }
524        HashAlgorithm::Fnv1a => {
525            const FNV_OFFSET: u64 = 14695981039346656037;
526            const FNV_PRIME: u64 = 1099511628211;
527            let mut hash = FNV_OFFSET;
528            for byte in content.bytes() {
529                hash ^= byte as u64;
530                hash = hash.wrapping_mul(FNV_PRIME);
531            }
532            format!("fnv1a:{hash:016x}")
533        }
534    }
535}
536
537/// MurmurHash3 x86_32 implementation.
538fn murmur3_32(data: &[u8], seed: u32) -> u32 {
539    const C1: u32 = 0xcc9e2d51;
540    const C2: u32 = 0x1b873593;
541    const R1: u32 = 15;
542    const R2: u32 = 13;
543    const M: u32 = 5;
544    const N: u32 = 0xe6546b64;
545
546    let mut hash = seed;
547    let len = data.len();
548
549    // Process 4-byte chunks using chunks_exact iteration.
550    let mut chunks = data.chunks_exact(4);
551    for chunk in &mut chunks {
552        let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
553        k = k.wrapping_mul(C1);
554        k = k.rotate_left(R1);
555        k = k.wrapping_mul(C2);
556        hash ^= k;
557        hash = hash.rotate_left(R2);
558        hash = hash.wrapping_mul(M).wrapping_add(N);
559    }
560
561    // Process remaining bytes
562    let tail = chunks.remainder();
563    let mut k1: u32 = 0;
564    match tail.len() {
565        3 => {
566            k1 ^= (tail[2] as u32) << 16;
567            k1 ^= (tail[1] as u32) << 8;
568            k1 ^= tail[0] as u32;
569        }
570        2 => {
571            k1 ^= (tail[1] as u32) << 8;
572            k1 ^= tail[0] as u32;
573        }
574        1 => {
575            k1 ^= tail[0] as u32;
576        }
577        _ => {}
578    }
579    if !tail.is_empty() {
580        k1 = k1.wrapping_mul(C1);
581        k1 = k1.rotate_left(R1);
582        k1 = k1.wrapping_mul(C2);
583        hash ^= k1;
584    }
585
586    // Finalization
587    hash ^= len as u32;
588    hash ^= hash >> 16;
589    hash = hash.wrapping_mul(0x85ebca6b);
590    hash ^= hash >> 13;
591    hash = hash.wrapping_mul(0xc2b2ae35);
592    hash ^= hash >> 16;
593
594    hash
595}
596
597/// Convert a model string to models.dev convention format.
598///
599/// # Example
600/// ```rust
601/// use vtcode_exec_events::trace::normalize_model_id;
602///
603/// assert_eq!(
604///     normalize_model_id("claude-3-opus-20240229", "anthropic"),
605///     "anthropic/claude-3-opus-20240229"
606/// );
607/// ```
608pub fn normalize_model_id(model: &str, provider: &str) -> String {
609    if model.contains('/') {
610        model.to_string()
611    } else {
612        format!("{provider}/{model}")
613    }
614}
615
616// ============================================================================
617// Serialization Helpers
618// ============================================================================
619
620/// Serialize [`uuid::Uuid`] as a hyphenated string.
621fn serialize_uuid<S>(uuid: &uuid::Uuid, serializer: S) -> Result<S::Ok, S::Error>
622where
623    S: serde::Serializer,
624{
625    serializer.serialize_str(&uuid.to_string())
626}
627
628/// Deserialize [`uuid::Uuid`] from a hyphenated string.
629fn deserialize_uuid<'de, D>(deserializer: D) -> Result<uuid::Uuid, D::Error>
630where
631    D: serde::Deserializer<'de>,
632{
633    let s = String::deserialize(deserializer)?;
634    uuid::Uuid::parse_str(&s).map_err(serde::de::Error::custom)
635}
636
637// ============================================================================
638// Builder Pattern
639// ============================================================================
640
641/// Builder for constructing trace records incrementally.
642#[derive(Debug, Default)]
643pub struct TraceRecordBuilder {
644    vcs: Option<VcsInfo>,
645    tool: Option<ToolInfo>,
646    files: Vec<TraceFile>,
647    metadata: Option<TraceMetadata>,
648}
649
650impl TraceRecordBuilder {
651    /// Create a new builder.
652    pub fn new() -> Self {
653        Self::default()
654    }
655
656    /// Set VCS information.
657    pub fn vcs(mut self, vcs: VcsInfo) -> Self {
658        self.vcs = Some(vcs);
659        self
660    }
661
662    /// Set git revision.
663    pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
664        self.vcs = Some(VcsInfo::git(revision));
665        self
666    }
667
668    /// Set tool information.
669    pub fn tool(mut self, tool: ToolInfo) -> Self {
670        self.tool = Some(tool);
671        self
672    }
673
674    /// Add a file.
675    pub fn file(mut self, file: TraceFile) -> Self {
676        self.files.push(file);
677        self
678    }
679
680    /// Set metadata.
681    pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
682        self.metadata = Some(metadata);
683        self
684    }
685
686    /// Build the trace record.
687    pub fn build(self) -> TraceRecord {
688        TraceRecord {
689            version: AGENT_TRACE_VERSION.to_string(),
690            id: uuid::Uuid::new_v4(),
691            timestamp: Utc::now(),
692            vcs: self.vcs,
693            tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
694            files: self.files,
695            metadata: self.metadata,
696        }
697    }
698}
699
700// ============================================================================
701// Conversion from TurnDiffTracker
702// ============================================================================
703
704/// Information needed to create a trace from file changes.
705#[derive(Debug, Clone)]
706pub struct TraceContext {
707    /// Git revision (commit SHA).
708    pub revision: Option<String>,
709    /// Session ID for conversation URL.
710    pub session_id: Option<String>,
711    /// Model ID in provider/model format.
712    pub model_id: String,
713    /// Provider name.
714    pub provider: String,
715    /// Turn number.
716    pub turn_number: Option<u32>,
717    /// Workspace path for resolving relative paths.
718    pub workspace_path: Option<PathBuf>,
719}
720
721impl TraceContext {
722    /// Create a new trace context.
723    pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
724        Self {
725            revision: None,
726            session_id: None,
727            model_id: model_id.into(),
728            provider: provider.into(),
729            turn_number: None,
730            workspace_path: None,
731        }
732    }
733
734    /// Set the git revision.
735    pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
736        self.revision = Some(revision.into());
737        self
738    }
739
740    /// Set the session ID.
741    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
742        self.session_id = Some(session_id.into());
743        self
744    }
745
746    /// Set the turn number.
747    pub fn with_turn_number(mut self, turn: u32) -> Self {
748        self.turn_number = Some(turn);
749        self
750    }
751
752    /// Set the workspace path.
753    pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
754        self.workspace_path = Some(path.into());
755        self
756    }
757
758    /// Get the normalized model ID.
759    pub fn normalized_model_id(&self) -> String {
760        normalize_model_id(&self.model_id, &self.provider)
761    }
762}
763
764#[cfg(test)]
765#[allow(clippy::expect_used, clippy::unwrap_used)]
766mod tests {
767    use super::*;
768
769    #[test]
770    fn test_trace_record_creation() {
771        let trace = TraceRecord::new();
772        assert_eq!(trace.version, AGENT_TRACE_VERSION);
773        assert!(trace.tool.is_some());
774        assert!(trace.files.is_empty());
775    }
776
777    #[test]
778    fn test_trace_record_for_git() {
779        let trace = TraceRecord::for_git_revision("abc123");
780        assert!(trace.vcs.is_some());
781        let vcs = trace.vcs.as_ref().expect("trace.vcs is None");
782        assert_eq!(vcs.vcs_type, VcsType::Git);
783        assert_eq!(vcs.revision, "abc123");
784    }
785
786    #[test]
787    fn test_contributor_types() {
788        let ai = Contributor::ai("anthropic/claude-opus-4");
789        assert_eq!(ai.contributor_type, ContributorType::Ai);
790        assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
791
792        let human = Contributor::human();
793        assert_eq!(human.contributor_type, ContributorType::Human);
794        assert!(human.model_id.is_none());
795    }
796
797    #[test]
798    fn test_trace_range() {
799        let range = TraceRange::new(10, 25);
800        assert_eq!(range.start_line, 10);
801        assert_eq!(range.end_line, 25);
802
803        let range_with_hash = range.with_content_hash("hello world");
804        assert!(range_with_hash.content_hash.is_some());
805        // Default is MurmurHash3 per Agent Trace spec
806        assert!(
807            range_with_hash
808                .content_hash
809                .unwrap()
810                .starts_with("murmur3:")
811        );
812    }
813
814    #[test]
815    fn test_hash_algorithms() {
816        let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
817        assert!(murmur.starts_with("murmur3:"));
818
819        let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
820        assert!(fnv.starts_with("fnv1a:"));
821
822        // Default should be MurmurHash3
823        let default_hash = compute_content_hash("hello world");
824        assert_eq!(default_hash, murmur);
825    }
826
827    #[test]
828    fn test_trace_file_builder() {
829        let file = TraceFile::with_ai_ranges(
830            "src/main.rs",
831            "anthropic/claude-opus-4",
832            vec![TraceRange::new(1, 50)],
833        );
834        assert_eq!(file.path, "src/main.rs");
835        assert_eq!(file.conversations.len(), 1);
836    }
837
838    #[test]
839    fn test_normalize_model_id() {
840        assert_eq!(
841            normalize_model_id("claude-3-opus", "anthropic"),
842            "anthropic/claude-3-opus"
843        );
844        assert_eq!(
845            normalize_model_id("anthropic/claude-3-opus", "anthropic"),
846            "anthropic/claude-3-opus"
847        );
848    }
849
850    #[test]
851    fn test_trace_record_builder() {
852        let trace = TraceRecordBuilder::new()
853            .git_revision("abc123def456")
854            .file(TraceFile::with_ai_ranges(
855                "src/lib.rs",
856                "openai/gpt-5",
857                vec![TraceRange::new(10, 20)],
858            ))
859            .build();
860
861        assert!(trace.vcs.is_some());
862        assert_eq!(trace.files.len(), 1);
863        assert!(trace.has_attributions());
864    }
865
866    #[test]
867    fn test_trace_serialization() {
868        let trace = TraceRecord::for_git_revision("abc123");
869        let json = serde_json::to_string_pretty(&trace).expect("Failed to serialize trace to JSON");
870        assert!(json.contains("\"version\": \"0.1.0\""));
871        assert!(json.contains("abc123"));
872
873        let restored: TraceRecord =
874            serde_json::from_str(&json).expect("Failed to deserialize trace from JSON");
875        assert_eq!(restored.version, trace.version);
876    }
877
878    #[test]
879    fn test_content_hash_consistency() {
880        // MurmurHash3 (default)
881        let hash1 = compute_content_hash("hello world");
882        let hash2 = compute_content_hash("hello world");
883        assert_eq!(hash1, hash2);
884
885        let hash3 = compute_content_hash("hello world!");
886        assert_ne!(hash1, hash3);
887
888        // FNV-1a
889        let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
890        let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
891        assert_eq!(fnv1, fnv2);
892    }
893}