use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTrace {
pub version: String,
pub id: String,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vcs: Option<VcsInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<ToolInfo>,
pub files: Vec<TraceFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sig: Option<LedgerSig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcsInfo {
#[serde(rename = "type")]
pub vcs_type: String,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceFile {
pub path: String,
pub conversations: Vec<Conversation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub contributor: Contributor,
pub ranges: Vec<TraceRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<RelatedResource>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contributor {
#[serde(rename = "type")]
pub contributor_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceRange {
pub start_line: u32,
pub end_line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contributor: Option<Contributor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedResource {
#[serde(rename = "type")]
pub resource_type: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentdiffMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_excerpt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_read: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_tool: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LedgerSig {
pub alg: String,
pub key_id: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub timestamp: DateTime<Utc>,
pub agent: String,
#[serde(default)]
pub mode: Option<String>,
pub model: String,
pub session_id: String,
pub tool: String,
pub file: String,
pub abs_file: String,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default = "default_acceptance")]
pub acceptance: String,
#[serde(default)]
pub lines: Vec<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub old: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_lines: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edit_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edits: Option<Vec<EditPair>>,
#[serde(skip)]
pub committed: bool,
#[serde(skip)]
pub commit_hash: String,
}
fn default_acceptance() -> String {
"verbatim".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditPair {
pub old: String,
pub new: String,
}
impl AgentTrace {
pub fn to_entries(&self, repo_root: &Path) -> Vec<Entry> {
let commit_hash = self
.vcs
.as_ref()
.map(|v| v.revision.clone())
.unwrap_or_default();
let agent_name = self
.tool
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_else(|| "unknown".to_string());
let ad_meta: Option<AgentdiffMetadata> = self
.metadata
.as_ref()
.and_then(|m| m.get("agentdiff"))
.and_then(|v| serde_json::from_value(v.clone()).ok());
let session_id = ad_meta
.as_ref()
.and_then(|m| m.session_id.clone())
.unwrap_or_default();
let prompt = ad_meta.as_ref().and_then(|m| m.prompt_excerpt.clone());
let capture_tool = ad_meta
.as_ref()
.and_then(|m| m.capture_tool.clone())
.unwrap_or_else(|| "commit".to_string());
let mut entries = Vec::new();
for file in &self.files {
let abs_file = repo_root.join(&file.path).to_string_lossy().to_string();
for conversation in &file.conversations {
let model = conversation
.contributor
.model_id
.clone()
.unwrap_or_default();
let mut lines = Vec::new();
for range in &conversation.ranges {
let lo = range.start_line.min(range.end_line);
let hi = range.start_line.max(range.end_line);
for ln in lo..=hi {
lines.push(ln);
}
}
lines.sort_unstable();
lines.dedup();
entries.push(Entry {
timestamp: self.timestamp,
agent: agent_name.clone(),
mode: None,
model,
session_id: session_id.clone(),
tool: capture_tool.clone(),
file: file.path.clone(),
abs_file: abs_file.clone(),
prompt: prompt.clone(),
acceptance: "verbatim".to_string(),
lines,
old: None,
new: None,
content_preview: None,
total_lines: None,
edit_count: None,
edits: None,
committed: true,
commit_hash: commit_hash.clone(),
});
}
}
entries
}
pub fn agentdiff_metadata(&self) -> Option<AgentdiffMetadata> {
self.metadata
.as_ref()
.and_then(|m| m.get("agentdiff"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn sha(&self) -> &str {
self.vcs
.as_ref()
.map(|v| v.revision.as_str())
.unwrap_or("")
}
pub fn agent_name(&self) -> &str {
self.tool.as_ref().map(|t| t.name.as_str()).unwrap_or("unknown")
}
}
#[cfg(test)]
fn expand_ranges(ranges: &[(u32, u32)]) -> Vec<u32> {
let mut out = Vec::new();
for &(start, end) in ranges {
if start == 0 || end == 0 {
continue;
}
let lo = start.min(end);
let hi = start.max(end);
out.extend(lo..=hi);
}
out.sort_unstable();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn sample_trace() -> AgentTrace {
AgentTrace {
version: "0.1.0".to_string(),
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
timestamp: Utc::now(),
vcs: Some(VcsInfo {
vcs_type: "git".to_string(),
revision: "abc123def456".to_string(),
}),
tool: Some(ToolInfo {
name: "claude-code".to_string(),
version: None,
}),
files: vec![TraceFile {
path: "src/main.rs".to_string(),
conversations: vec![Conversation {
url: None,
contributor: Contributor {
contributor_type: "ai".to_string(),
model_id: Some("anthropic/claude-sonnet-4-6".to_string()),
},
ranges: vec![
TraceRange {
start_line: 10,
end_line: 20,
content_hash: None,
contributor: None,
},
TraceRange {
start_line: 30,
end_line: 35,
content_hash: None,
contributor: None,
},
],
related: None,
}],
}],
metadata: Some(serde_json::json!({
"agentdiff": {
"prompt_excerpt": "add auth middleware",
"session_id": "sess-123",
"trust": 92,
"flags": ["security"],
"intent": "security hardening",
"author": "Prakhar Khatri"
}
})),
sig: None,
}
}
#[test]
fn test_serialize_roundtrip() {
let trace = sample_trace();
let json = serde_json::to_string(&trace).unwrap();
let parsed: AgentTrace = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, "0.1.0");
assert_eq!(parsed.id, trace.id);
assert_eq!(parsed.files.len(), 1);
assert_eq!(parsed.files[0].conversations[0].ranges.len(), 2);
}
#[test]
fn test_to_entries() {
let trace = sample_trace();
let entries = trace.to_entries(&PathBuf::from("/repo"));
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.agent, "claude-code");
assert_eq!(e.model, "anthropic/claude-sonnet-4-6");
assert_eq!(e.commit_hash, "abc123def456");
assert_eq!(e.file, "src/main.rs");
assert_eq!(e.lines.len(), 17);
assert_eq!(e.lines[0], 10);
assert_eq!(*e.lines.last().unwrap(), 35);
assert_eq!(e.prompt.as_deref(), Some("add auth middleware"));
assert_eq!(e.session_id, "sess-123");
}
#[test]
fn test_agentdiff_metadata() {
let trace = sample_trace();
let meta = trace.agentdiff_metadata().unwrap();
assert_eq!(meta.trust, Some(92));
assert_eq!(meta.intent.as_deref(), Some("security hardening"));
assert_eq!(meta.flags, vec!["security"]);
}
#[test]
fn test_convenience_methods() {
let trace = sample_trace();
assert_eq!(trace.sha(), "abc123def456");
assert_eq!(trace.agent_name(), "claude-code");
}
#[test]
fn test_minimal_trace() {
let json = r#"{
"version": "0.1.0",
"id": "test-uuid",
"timestamp": "2026-01-25T10:00:00Z",
"files": [{
"path": "src/app.ts",
"conversations": [{
"contributor": { "type": "ai" },
"ranges": [{ "start_line": 1, "end_line": 50 }]
}]
}]
}"#;
let trace: AgentTrace = serde_json::from_str(json).unwrap();
assert_eq!(trace.id, "test-uuid");
assert!(trace.vcs.is_none());
assert!(trace.tool.is_none());
assert!(trace.metadata.is_none());
assert_eq!(trace.files[0].conversations[0].ranges[0].end_line, 50);
}
#[test]
fn test_expand_ranges() {
assert_eq!(expand_ranges(&[(1, 3), (5, 5)]), vec![1, 2, 3, 5]);
assert_eq!(expand_ranges(&[(3, 1)]), vec![1, 2, 3]); assert_eq!(expand_ranges(&[(0, 5)]), Vec::<u32>::new()); }
}