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