use std::collections::{HashMap, HashSet};
use crate::model::Change;
use super::model::{TraceRecord, TraceVcsType};
const MIN_REVISION_LEN: usize = 8;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AiBadgeSets {
pub confirmed: HashSet<String>,
pub heuristic: HashSet<String>,
}
impl AiBadgeSets {
pub fn is_empty(&self) -> bool {
self.confirmed.is_empty() && self.heuristic.is_empty()
}
}
#[derive(Debug, Clone)]
struct AnchoredRecord {
vcs_type: TraceVcsType,
revision: String,
record: TraceRecord,
}
#[derive(Debug, Clone, Default)]
pub struct TraceIndex {
anchored: Vec<AnchoredRecord>,
}
impl TraceIndex {
pub fn build(records: &[TraceRecord]) -> Self {
let mut index = TraceIndex::default();
for record in records {
if !record.has_ai_contribution() {
continue;
}
let Some(vcs) = &record.vcs else { continue };
if vcs.revision.len() < MIN_REVISION_LEN {
continue;
}
if vcs.vcs_type == TraceVcsType::Other {
continue;
}
index.anchored.push(AnchoredRecord {
vcs_type: vcs.vcs_type,
revision: vcs.revision.clone(),
record: record.clone(),
});
}
index
}
pub fn is_empty(&self) -> bool {
self.anchored.is_empty()
}
pub fn match_commits(&self, changes: &[Change]) -> AiBadgeSets {
let mut sets = AiBadgeSets::default();
for change in changes {
if change.is_graph_only {
continue;
}
let change_id = change.change_id.as_str();
let commit_id = change.commit_id.as_str();
if change_id.is_empty() || commit_id.is_empty() {
continue;
}
if self
.anchored
.iter()
.any(|a| a.vcs_type == TraceVcsType::Jj && a.revision.starts_with(change_id))
{
sets.confirmed.insert(commit_id.to_string());
} else if self
.anchored
.iter()
.any(|a| a.vcs_type == TraceVcsType::Git && a.revision.starts_with(commit_id))
{
sets.heuristic.insert(commit_id.to_string());
}
}
sets
}
pub fn records_for(&self, change_id: &str, commit_id: &str) -> Vec<&TraceRecord> {
if change_id.is_empty() || commit_id.is_empty() {
return Vec::new();
}
self.anchored
.iter()
.filter(|a| match a.vcs_type {
TraceVcsType::Jj => a.revision.starts_with(change_id),
TraceVcsType::Git => a.revision.starts_with(commit_id),
TraceVcsType::Other => false,
})
.map(|a| &a.record)
.collect()
}
pub fn ai_ranges_for(
&self,
change_id: &str,
commit_id: &str,
) -> HashMap<String, Vec<(usize, usize)>> {
use super::model::ContributorKind;
let mut by_file: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for record in self.records_for(change_id, commit_id) {
let tool_fallback = record.tool_name.is_some();
for file in &record.files {
for conv in &file.conversations {
for range in &conv.ranges {
let effective = range.contributor.as_ref().or(conv.contributor.as_ref());
let is_ai = match effective {
Some(c) => {
matches!(c.kind, ContributorKind::Ai | ContributorKind::Mixed)
}
None => tool_fallback,
};
if is_ai {
by_file
.entry(file.path.clone())
.or_default()
.push((range.start_line, range.end_line));
}
}
}
}
}
by_file
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ChangeId, CommitId};
use crate::trace::model::{TraceVcs, TraceVcsType};
fn record(vcs_type: TraceVcsType, revision: &str) -> TraceRecord {
use crate::trace::model::{
ContributorKind, TraceContributor, TraceConversation, TraceFile,
};
TraceRecord {
timestamp: String::new(),
vcs: Some(TraceVcs {
vcs_type,
revision: revision.to_string(),
}),
tool_name: Some("claude-code".to_string()),
tool_version: None,
files: vec![TraceFile {
path: "src/main.rs".to_string(),
conversations: vec![TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: None,
}),
ranges: vec![],
}],
}],
}
}
fn change(change_id: &str, commit_id: &str) -> Change {
Change {
change_id: ChangeId::new(change_id.to_string()),
commit_id: CommitId::new(commit_id.to_string()),
author: String::new(),
timestamp: String::new(),
description: String::new(),
is_working_copy: false,
is_empty: false,
bookmarks: vec![],
graph_prefix: String::new(),
is_graph_only: false,
has_conflict: false,
working_copy_names: vec![],
}
}
#[test]
fn jj_revision_matches_change_id_as_confirmed() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let changes = [change("xqnktzml", "2d31c7f1")];
let sets = index.match_commits(&changes);
assert!(sets.confirmed.contains("2d31c7f1"));
assert!(sets.heuristic.is_empty());
}
#[test]
fn git_revision_matches_commit_id_as_heuristic() {
let index = TraceIndex::build(&[record(
TraceVcsType::Git,
"a6b2ed5ac3b509694c746a4763b97995f395172b",
)]);
let changes = [change("rlxnnrwv", "a6b2ed5a")];
let sets = index.match_commits(&changes);
assert!(sets.heuristic.contains("a6b2ed5a"));
assert!(sets.confirmed.is_empty());
}
#[test]
fn unmatched_changes_get_no_badge() {
let index = TraceIndex::build(&[record(TraceVcsType::Git, "deadbeef00000000")]);
let sets = index.match_commits(&[change("zzzzzzzz", "00000000")]);
assert!(sets.is_empty());
}
#[test]
fn non_ai_records_are_excluded() {
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files[0].conversations[0].contributor = Some(crate::trace::model::TraceContributor {
kind: crate::trace::model::ContributorKind::Human,
model_id: None,
});
let index = TraceIndex::build(&[r]);
assert!(index.is_empty());
}
#[test]
fn short_revisions_are_rejected() {
let index = TraceIndex::build(&[record(TraceVcsType::Jj, "ab")]);
assert!(index.is_empty());
}
#[test]
fn vcs_less_records_are_excluded() {
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.vcs = None;
let index = TraceIndex::build(&[r]);
assert!(index.is_empty());
}
#[test]
fn records_for_returns_jj_anchored_records() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let records = index.records_for("xqnktzml", "2d31c7f1");
assert_eq!(records.len(), 1);
assert_eq!(records[0].tool_name.as_deref(), Some("claude-code"));
}
#[test]
fn records_for_returns_git_anchored_records() {
let index = TraceIndex::build(&[record(
TraceVcsType::Git,
"a6b2ed5ac3b509694c746a4763b97995f395172b",
)]);
assert_eq!(index.records_for("rlxnnrwv", "a6b2ed5a").len(), 1);
assert_eq!(index.records_for("rlxnnrwv", "deadbeef").len(), 0);
}
#[test]
fn records_for_empty_ids_returns_nothing() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
assert!(index.records_for("", "").is_empty());
}
#[test]
fn ai_ranges_for_collects_ranges_per_file() {
use crate::trace::model::TraceRange;
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files[0].conversations[0].ranges = vec![
TraceRange {
start_line: 1,
end_line: 10,
contributor: None,
},
TraceRange {
start_line: 20,
end_line: 25,
contributor: None,
},
];
let index = TraceIndex::build(&[r]);
let ranges = index.ai_ranges_for("xqnktzml", "2d31c7f1");
assert_eq!(
ranges.get("src/main.rs"),
Some(&vec![(1, 10), (20, 25)]),
"conversation-level ai contributor applies to its ranges"
);
assert!(index.ai_ranges_for("zzzzzzzz", "00000000").is_empty());
}
#[test]
fn ai_ranges_for_excludes_human_ranges() {
use crate::trace::model::{ContributorKind, TraceContributor, TraceRange};
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files[0].conversations[0].ranges = vec![
TraceRange {
start_line: 1,
end_line: 5,
contributor: Some(TraceContributor {
kind: ContributorKind::Human,
model_id: None,
}),
},
TraceRange {
start_line: 6,
end_line: 9,
contributor: None,
},
];
let index = TraceIndex::build(&[r]);
let ranges = index.ai_ranges_for("xqnktzml", "2d31c7f1");
assert_eq!(ranges.get("src/main.rs"), Some(&vec![(6, 9)]));
}
#[test]
fn graph_only_rows_are_skipped() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let mut c = change("xqnktzml", "2d31c7f1");
c.is_graph_only = true;
let sets = index.match_commits(&[c]);
assert!(sets.is_empty());
}
}