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