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