#[derive(Debug, Clone, Default)]
pub struct TraceRecord {
pub timestamp: String,
pub vcs: Option<TraceVcs>,
pub tool_name: Option<String>,
pub tool_version: Option<String>,
pub files: Vec<TraceFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceVcs {
pub vcs_type: TraceVcsType,
pub revision: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TraceVcsType {
Git,
Jj,
Other,
}
#[derive(Debug, Clone, Default)]
pub struct TraceFile {
pub path: String,
pub conversations: Vec<TraceConversation>,
}
#[derive(Debug, Clone, Default)]
pub struct TraceConversation {
pub url: Option<String>,
pub contributor: Option<TraceContributor>,
pub ranges: Vec<TraceRange>,
pub related: Vec<TraceRelated>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceRelated {
pub rel_type: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceContributor {
pub kind: ContributorKind,
pub model_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContributorKind {
Human,
Ai,
Mixed,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceRange {
pub start_line: usize,
pub end_line: usize,
pub contributor: Option<TraceContributor>,
}
const PSEUDO_FILES: [&str; 2] = [".shell-history", ".sessions"];
impl TraceFile {
pub fn is_pseudo(&self) -> bool {
PSEUDO_FILES.contains(&self.path.as_str())
}
}
impl TraceRecord {
pub fn code_files(&self) -> impl Iterator<Item = &TraceFile> {
self.files.iter().filter(|f| !f.is_pseudo())
}
pub fn has_ai_contribution(&self) -> bool {
if self.code_files().next().is_none() {
return false;
}
let mut saw_contributor = false;
for file in self.code_files() {
for conv in &file.conversations {
if let Some(c) = &conv.contributor {
saw_contributor = true;
if matches!(c.kind, ContributorKind::Ai | ContributorKind::Mixed) {
return true;
}
}
for range in &conv.ranges {
if let Some(c) = &range.contributor {
saw_contributor = true;
if matches!(c.kind, ContributorKind::Ai | ContributorKind::Mixed) {
return true;
}
}
}
}
}
!saw_contributor && self.tool_name.is_some()
}
pub fn primary_model_id(&self) -> Option<&str> {
self.files
.iter()
.flat_map(|f| &f.conversations)
.find_map(|c| {
c.contributor
.as_ref()
.and_then(|ct| ct.model_id.as_deref())
.or_else(|| {
c.ranges
.iter()
.find_map(|r| r.contributor.as_ref()?.model_id.as_deref())
})
})
}
pub fn code_file_count(&self) -> usize {
self.code_files().count()
}
pub fn model_ids(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for conv in self.code_files().flat_map(|f| &f.conversations) {
let conv_model = conv
.contributor
.as_ref()
.and_then(|c| c.model_id.as_deref());
if let Some(m) = conv_model
&& !out.contains(&m)
{
out.push(m);
}
for range in &conv.ranges {
let rm = range
.contributor
.as_ref()
.and_then(|c| c.model_id.as_deref());
if let Some(m) = rm
&& !out.contains(&m)
{
out.push(m);
}
}
}
out
}
pub fn all_urls(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
for conv in self.files.iter().flat_map(|f| &f.conversations) {
if let Some(url) = &conv.url {
out.push(("conversation".to_string(), url.clone()));
}
for rel in &conv.related {
out.push((rel.rel_type.clone(), rel.url.clone()));
}
}
out
}
pub fn contributor_counts(&self) -> ContributorCounts {
let tool_fallback = self.tool_name.is_some();
let mut counts = ContributorCounts::default();
for file in self.code_files() {
for conv in &file.conversations {
for range in &conv.ranges {
let effective = range.contributor.as_ref().or(conv.contributor.as_ref());
let kind = match effective {
Some(c) => c.kind,
None if tool_fallback => ContributorKind::Ai,
None => ContributorKind::Unknown,
};
match kind {
ContributorKind::Ai => counts.ai += 1,
ContributorKind::Mixed => counts.mixed += 1,
ContributorKind::Human => counts.human += 1,
ContributorKind::Unknown => counts.unknown += 1,
}
}
}
}
counts
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ContributorCounts {
pub ai: usize,
pub mixed: usize,
pub human: usize,
pub unknown: usize,
}
impl ContributorCounts {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.ai > 0 {
parts.push(format!("ai×{}", self.ai));
}
if self.mixed > 0 {
parts.push(format!("mixed×{}", self.mixed));
}
if self.human > 0 {
parts.push(format!("human×{}", self.human));
}
if self.unknown > 0 {
parts.push(format!("unknown×{}", self.unknown));
}
if parts.is_empty() {
"—".to_string()
} else {
parts.join(" ")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn range(start: usize, end: usize, kind: Option<ContributorKind>) -> TraceRange {
TraceRange {
start_line: start,
end_line: end,
contributor: kind.map(|k| TraceContributor {
kind: k,
model_id: None,
}),
}
}
fn record(conv_kind: Option<ContributorKind>, ranges: Vec<TraceRange>) -> TraceRecord {
TraceRecord {
timestamp: "t".to_string(),
vcs: None,
tool_name: Some("claude-code".to_string()),
tool_version: None,
files: vec![TraceFile {
path: "a.rs".to_string(),
conversations: vec![TraceConversation {
url: None,
contributor: conv_kind.map(|k| TraceContributor {
kind: k,
model_id: None,
}),
ranges,
related: vec![],
}],
}],
}
}
#[test]
fn contributor_counts_and_file_count_exclude_pseudo_files() {
let mut r = record(Some(ContributorKind::Ai), vec![range(1, 5, None)]);
r.files.push(TraceFile {
path: ".shell-history".to_string(),
conversations: vec![TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: None,
}),
ranges: vec![range(1, 99, None)],
related: vec![],
}],
});
assert_eq!(r.contributor_counts().ai, 1);
assert_eq!(r.code_file_count(), 1);
}
#[test]
fn contributor_counts_use_effective_rule() {
let r = record(
Some(ContributorKind::Ai),
vec![range(1, 5, None), range(6, 9, Some(ContributorKind::Human))],
);
let c = r.contributor_counts();
assert_eq!((c.ai, c.human, c.mixed, c.unknown), (1, 1, 0, 0));
assert_eq!(c.summary(), "ai×1 human×1");
}
#[test]
fn contributor_counts_tool_fallback_when_no_contributor() {
let r = record(None, vec![range(1, 3, None), range(4, 6, None)]);
assert_eq!(r.contributor_counts().ai, 2);
}
#[test]
fn contributor_counts_ignore_conversations_without_ranges() {
let r = record(Some(ContributorKind::Ai), vec![]);
assert_eq!(r.contributor_counts(), ContributorCounts::default());
assert_eq!(r.contributor_counts().summary(), "—");
}
#[test]
fn all_urls_orders_conversation_then_related() {
let mut r = record(Some(ContributorKind::Ai), vec![range(1, 2, None)]);
r.files[0].conversations[0].url = Some("conv".to_string());
r.files[0].conversations[0].related = vec![TraceRelated {
rel_type: "pr".to_string(),
url: "prurl".to_string(),
}];
assert_eq!(
r.all_urls(),
vec![
("conversation".to_string(), "conv".to_string()),
("pr".to_string(), "prurl".to_string()),
]
);
}
#[test]
fn model_ids_excludes_pseudo_files() {
let mut r = record(Some(ContributorKind::Ai), vec![range(1, 2, None)]);
r.files[0].conversations[0]
.contributor
.as_mut()
.unwrap()
.model_id = Some("opus".to_string());
r.files.push(TraceFile {
path: ".shell-history".to_string(),
conversations: vec![TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: Some("gpt".to_string()),
}),
ranges: vec![],
related: vec![],
}],
});
assert_eq!(r.model_ids(), vec!["opus"], "pseudo-file model excluded");
}
}