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, Copy, PartialEq, Eq)]
pub enum AiConfidence {
Confirmed,
Heuristic,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TraceSummary {
pub total: usize,
pub ai_total: usize,
pub ai_confirmed: usize,
pub ai_heuristic: usize,
pub by_model: std::collections::BTreeMap<String, usize>,
}
impl TraceSummary {
pub fn ai_percent(&self) -> u32 {
if self.total == 0 {
0
} else {
((self.ai_total as f64 / self.total as f64) * 100.0).round() as u32
}
}
pub fn one_line(&self) -> String {
let models = if self.by_model.is_empty() {
"—".to_string()
} else {
let mut entries: Vec<(&String, &usize)> = self.by_model.iter().collect();
entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
entries
.iter()
.map(|(name, n)| format!("{} ×{}", name, n))
.collect::<Vec<_>>()
.join(", ")
};
format!(
"AI {}/{} ({}%) · [AI] {} [AI?] {} · models: {}",
self.ai_total,
self.total,
self.ai_percent(),
self.ai_confirmed,
self.ai_heuristic,
models
)
}
}
#[derive(Debug, Clone)]
struct AnchoredRecord {
vcs_type: TraceVcsType,
revision: String,
record: TraceRecord,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrphanedAnchor {
pub vcs_type: TraceVcsType,
pub revision: String,
pub files: Vec<String>,
pub url: Option<String>,
}
#[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()
}
fn classify_into(&self, change_id: &str, commit_id: &str, sets: &mut AiBadgeSets) {
if change_id.is_empty() || commit_id.is_empty() {
return;
}
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());
}
}
pub fn match_commits(&self, changes: &[Change]) -> AiBadgeSets {
let mut sets = AiBadgeSets::default();
for change in changes {
if change.is_graph_only {
continue;
}
self.classify_into(
change.change_id.as_str(),
change.commit_id.as_str(),
&mut sets,
);
}
sets
}
pub fn ai_status(&self, change_id: &str, commit_id: &str) -> Option<AiConfidence> {
if change_id.is_empty() || commit_id.is_empty() {
return None;
}
if self
.anchored
.iter()
.any(|a| a.vcs_type == TraceVcsType::Jj && a.revision.starts_with(change_id))
{
Some(AiConfidence::Confirmed)
} else if self
.anchored
.iter()
.any(|a| a.vcs_type == TraceVcsType::Git && a.revision.starts_with(commit_id))
{
Some(AiConfidence::Heuristic)
} else {
None
}
}
pub fn summarize(&self, changes: &[Change]) -> TraceSummary {
let mut s = TraceSummary::default();
for change in changes {
if change.is_graph_only {
continue;
}
s.total += 1;
let cid = change.change_id.as_str();
let coid = change.commit_id.as_str();
match self.ai_status(cid, coid) {
Some(AiConfidence::Confirmed) => {
s.ai_total += 1;
s.ai_confirmed += 1;
}
Some(AiConfidence::Heuristic) => {
s.ai_total += 1;
s.ai_heuristic += 1;
}
None => continue,
}
let mut seen = std::collections::BTreeSet::new();
for record in self.records_for(cid, coid) {
for model in record.model_ids() {
if seen.insert(model.to_string()) {
*s.by_model.entry(model.to_string()).or_insert(0) += 1;
}
}
}
}
s
}
pub fn match_blame_lines(&self, lines: &[(&str, &str)]) -> AiBadgeSets {
let mut sets = AiBadgeSets::default();
for &(change_id, commit_id) in lines {
self.classify_into(change_id, commit_id, &mut sets);
}
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.code_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
}
pub fn orphaned_anchors(&self, changes: &[Change]) -> Vec<OrphanedAnchor> {
let mut seen: HashMap<(TraceVcsType, &str), usize> = HashMap::new();
let mut out: Vec<OrphanedAnchor> = Vec::new();
for anchor in &self.anchored {
let matched = changes.iter().any(|c| anchor_matches_change(anchor, c));
if matched {
continue;
}
let key = (anchor.vcs_type, anchor.revision.as_str());
let idx = *seen.entry(key).or_insert_with(|| {
out.push(OrphanedAnchor {
vcs_type: anchor.vcs_type,
revision: anchor.revision.clone(),
files: Vec::new(),
url: None,
});
out.len() - 1
});
let entry = &mut out[idx];
for file in anchor.record.code_files() {
if !entry.files.contains(&file.path) {
entry.files.push(file.path.clone());
}
}
if entry.url.is_none() {
entry.url = anchor
.record
.all_urls()
.into_iter()
.map(|(_, url)| url)
.next();
}
}
out
}
}
fn anchor_matches_change(anchor: &AnchoredRecord, change: &Change) -> bool {
if change.is_graph_only {
return false;
}
match anchor.vcs_type {
TraceVcsType::Jj => {
let cid = change.change_id.as_str();
!cid.is_empty() && anchor.revision.starts_with(cid)
}
TraceVcsType::Git => {
let coid = change.commit_id.as_str();
!coid.is_empty() && anchor.revision.starts_with(coid)
}
TraceVcsType::Other => false,
}
}
#[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![],
related: 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![],
}
}
fn record_model(vcs_type: TraceVcsType, revision: &str, model: &str) -> TraceRecord {
let mut r = record(vcs_type, revision);
r.files[0].conversations[0]
.contributor
.as_mut()
.unwrap()
.model_id = Some(model.to_string());
r
}
#[test]
fn summarize_counts_confidence_and_models() {
let index = TraceIndex::build(&[
record_model(TraceVcsType::Jj, "aaaaaaaa1111", "opus"),
record_model(TraceVcsType::Jj, "bbbbbbbb2222", "opus"),
record_model(
TraceVcsType::Git,
"cccccccc3333333333333333333333333333cccc",
"gpt",
),
]);
let changes = [
change("aaaaaaaa", "dead0001"), change("bbbbbbbb", "dead0002"), change("zzzzzzzz", "cccccccc3333333333333333333333333333cccc"), change("nomatch1", "nomatch1"), ];
let s = index.summarize(&changes);
assert_eq!(s.total, 4);
assert_eq!(s.ai_total, 3);
assert_eq!(s.ai_confirmed, 2);
assert_eq!(s.ai_heuristic, 1);
assert_eq!(s.ai_confirmed + s.ai_heuristic, s.ai_total);
assert_eq!(s.by_model.get("opus"), Some(&2));
assert_eq!(s.by_model.get("gpt"), Some(&1));
assert_eq!(s.ai_percent(), 75);
}
#[test]
fn summarize_skips_graph_only_and_handles_empty() {
let index = TraceIndex::build(&[record(TraceVcsType::Jj, "aaaaaaaa1111")]);
let mut graph = change("zzzz", "zzzz");
graph.is_graph_only = true;
let changes = [change("aaaaaaaa", "d1"), graph];
let s = index.summarize(&changes);
assert_eq!(s.total, 1, "graph-only excluded");
assert_eq!(s.ai_total, 1);
let empty = index.summarize(&[]);
assert_eq!(empty.total, 0);
assert_eq!(empty.ai_percent(), 0);
}
#[test]
fn summarize_one_line_format() {
let index = TraceIndex::build(&[record_model(TraceVcsType::Jj, "aaaaaaaa1111", "opus")]);
let s = index.summarize(&[change("aaaaaaaa", "d1"), change("nomatch", "nomatch")]);
assert_eq!(
s.one_line(),
"AI 1/2 (50%) · [AI] 1 [AI?] 0 · models: opus ×1"
);
let none = index.summarize(&[change("nomatch", "nomatch")]);
assert_eq!(none.one_line(), "AI 0/1 (0%) · [AI] 0 [AI?] 0 · models: —");
}
#[test]
fn summarize_model_tally_dedups_within_change() {
let index = TraceIndex::build(&[
record_model(TraceVcsType::Jj, "aaaaaaaa1111", "opus"),
record_model(TraceVcsType::Jj, "aaaaaaaa1111", "opus"),
]);
let s = index.summarize(&[change("aaaaaaaa", "d1")]);
assert_eq!(s.ai_total, 1);
assert_eq!(
s.by_model.get("opus"),
Some(&1),
"same model once per change"
);
}
#[test]
fn summarize_change_with_two_models_counts_each() {
let index = TraceIndex::build(&[
record_model(TraceVcsType::Jj, "aaaaaaaa1111", "opus"),
record_model(TraceVcsType::Jj, "aaaaaaaa1111", "gpt"),
]);
let s = index.summarize(&[change("aaaaaaaa", "d1")]);
assert_eq!(s.ai_total, 1);
assert_eq!(s.by_model.get("opus"), Some(&1));
assert_eq!(s.by_model.get("gpt"), Some(&1));
}
#[test]
fn summarize_collects_multiple_models_in_one_record() {
use crate::trace::model::{ContributorKind, TraceContributor, TraceConversation};
let mut r = record(TraceVcsType::Jj, "aaaaaaaa1111");
r.files[0].conversations[0]
.contributor
.as_mut()
.unwrap()
.model_id = Some("opus".to_string());
r.files[0].conversations.push(TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: ContributorKind::Ai,
model_id: Some("gpt".to_string()),
}),
ranges: vec![],
related: vec![],
});
let index = TraceIndex::build(&[r]);
let s = index.summarize(&[change("aaaaaaaa", "d1")]);
assert_eq!(s.by_model.get("opus"), Some(&1));
assert_eq!(
s.by_model.get("gpt"),
Some(&1),
"2nd conversation's model counted"
);
}
#[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 match_blame_lines_classifies_jj_and_git() {
let index = TraceIndex::build(&[
record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv"),
record(
TraceVcsType::Git,
"a6b2ed5ac3b509694c746a4763b97995f395172b",
),
]);
let lines = [
("xqnktzml", "2d31c7f1"),
("rlxnnrwv", "a6b2ed5a"),
("zzzzzzzz", "00000000"),
];
let sets = index.match_blame_lines(&lines);
assert!(sets.confirmed.contains("2d31c7f1"));
assert!(sets.heuristic.contains("a6b2ed5a"));
assert!(!sets.confirmed.contains("00000000"));
assert!(!sets.heuristic.contains("00000000"));
}
#[test]
fn match_blame_lines_skips_empty_ids() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let sets = index.match_blame_lines(&[("", ""), ("xqnktzml", "")]);
assert!(sets.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 ai_ranges_for_excludes_pseudo_files() {
use crate::trace::model::{
ContributorKind, TraceContributor, TraceConversation, TraceFile, TraceRange,
};
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files[0].conversations[0].ranges = vec![TraceRange {
start_line: 1,
end_line: 8,
contributor: 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![TraceRange {
start_line: 100,
end_line: 200,
contributor: None,
}],
related: vec![],
}],
});
let index = TraceIndex::build(&[r]);
let ranges = index.ai_ranges_for("xqnktzml", "2d31c7f1");
assert_eq!(ranges.get("src/main.rs"), Some(&vec![(1, 8)]));
assert!(
!ranges.contains_key(".shell-history"),
"pseudo-file ranges must not enter ai_ranges_for"
);
}
#[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());
}
#[test]
fn orphaned_anchors_empty_when_all_match() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let orphans = index.orphaned_anchors(&[change("xqnktzml", "2d31c7f1")]);
assert!(orphans.is_empty());
}
#[test]
fn orphaned_anchors_flags_unmatched_jj() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "00000000")]);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].vcs_type, TraceVcsType::Jj);
assert!(orphans[0].revision.starts_with("xqnktzml"));
assert_eq!(orphans[0].files, vec!["src/main.rs".to_string()]);
}
#[test]
fn orphaned_anchors_flags_unmatched_git() {
let index = TraceIndex::build(&[record(TraceVcsType::Git, "deadbeef00000000")]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "11111111")]);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].vcs_type, TraceVcsType::Git);
}
#[test]
fn orphaned_anchors_aggregates_same_anchor() {
let mut r2 = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r2.files[0].path = "src/other.rs".to_string();
let index = TraceIndex::build(&[
record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv"),
r2,
]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "00000000")]);
assert_eq!(orphans.len(), 1, "same (vcs, revision) collapses to one");
assert_eq!(
orphans[0].files,
vec!["src/main.rs".to_string(), "src/other.rs".to_string()],
"files unioned across records"
);
}
#[test]
fn orphaned_anchors_same_revision_distinct_vcs() {
let shared = "abcdefgh12345678";
let index = TraceIndex::build(&[
record(TraceVcsType::Jj, shared),
record(TraceVcsType::Git, shared),
]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "00000000")]);
assert_eq!(
orphans.len(),
2,
"(vcs_type, revision) keeps kinds distinct"
);
assert!(orphans.iter().any(|o| o.vcs_type == TraceVcsType::Jj));
assert!(orphans.iter().any(|o| o.vcs_type == TraceVcsType::Git));
}
#[test]
fn orphaned_anchors_graph_only_change_does_not_match() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let mut c = change("xqnktzml", "2d31c7f1");
c.is_graph_only = true;
let orphans = index.orphaned_anchors(&[c]);
assert_eq!(orphans.len(), 1);
}
#[test]
fn orphaned_anchors_excludes_pseudo_files() {
use crate::trace::model::{TraceContributor, TraceConversation, TraceFile};
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files.push(TraceFile {
path: ".shell-history".to_string(),
conversations: vec![TraceConversation {
url: None,
contributor: Some(TraceContributor {
kind: super::super::model::ContributorKind::Ai,
model_id: None,
}),
ranges: vec![],
related: vec![],
}],
});
let index = TraceIndex::build(&[r]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "00000000")]);
assert_eq!(orphans.len(), 1);
assert_eq!(
orphans[0].files,
vec!["src/main.rs".to_string()],
"pseudo-files excluded from orphan files"
);
}
#[test]
fn orphaned_anchors_url_from_related_only() {
use crate::trace::model::TraceRelated;
let mut r = record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv");
r.files[0].conversations[0].url = None;
r.files[0].conversations[0].related = vec![TraceRelated {
rel_type: "pull-request".to_string(),
url: "pr-url".to_string(),
}];
let index = TraceIndex::build(&[r]);
let orphans = index.orphaned_anchors(&[change("zzzzzzzz", "00000000")]);
assert_eq!(orphans.len(), 1);
assert_eq!(
orphans[0].url.as_deref(),
Some("pr-url"),
"related-only orphan still surfaces a URL (all_urls, not primary_url)"
);
}
#[test]
fn orphaned_anchors_empty_change_ids_do_not_match() {
let index =
TraceIndex::build(&[record(TraceVcsType::Jj, "xqnktzmlworukplnyrropmtzylsuxxlv")]);
let orphans = index.orphaned_anchors(&[change("", "")]);
assert_eq!(orphans.len(), 1);
}
}