#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoAuthor {
pub name: String,
pub email: String,
}
#[must_use]
pub fn co_authors(message: &str) -> Vec<CoAuthor> {
const KEY: &str = "co-authored-by:";
let mut out = Vec::new();
for line in message.lines() {
let line = line.trim();
let Some(head) = line.get(..KEY.len()) else {
continue;
};
if !head.eq_ignore_ascii_case(KEY) {
continue;
}
let value = line[KEY.len()..].trim();
let (name, email) = match value.rfind('<') {
Some(at) => {
let email = value[at + 1..].trim_end().trim_end_matches('>');
(value[..at].trim(), email.trim())
}
None => (value, ""),
};
if name.is_empty() && email.is_empty() {
continue;
}
out.push(CoAuthor {
name: name.to_owned(),
email: email.to_owned(),
});
}
out
}
#[must_use]
pub fn identity_tokens(name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut depth = 0usize;
for ch in name.chars() {
match ch {
'(' | '[' => {
depth += 1;
continue;
}
')' | ']' => {
depth = depth.saturating_sub(1);
continue;
}
_ => {}
}
if depth > 0 {
continue;
}
if ch.is_alphanumeric() {
current.extend(ch.to_lowercase());
} else if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
out.push(current);
}
out
}
#[must_use]
pub fn names_same_model(name: &str, model: &str) -> bool {
let left = identity_tokens(name);
!left.is_empty() && left == identity_tokens(model)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OwnWork {
pub commits: usize,
pub names: Vec<String>,
}
impl OwnWork {
#[must_use]
pub fn is_empty(&self) -> bool {
self.commits == 0
}
}
#[must_use]
pub fn reviewers_own_work(messages: &[String], model: &str) -> OwnWork {
let mut out = OwnWork::default();
for message in messages {
let mut matched = false;
for author in co_authors(message) {
if !names_same_model(&author.name, model) {
continue;
}
matched = true;
if !out.names.contains(&author.name) {
out.names.push(author.name);
}
}
out.commits += usize::from(matched);
}
out
}
#[cfg(test)]
mod tests {
use super::{co_authors, identity_tokens, names_same_model, reviewers_own_work};
#[test]
fn a_trailer_is_read_from_a_real_commit_message() {
let message = "feat(review): do the thing\n\
\n\
A body paragraph.\n\
\n\
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n\
Claude-Session: https://example.invalid/s\n";
let authors = co_authors(message);
assert_eq!(authors.len(), 1);
assert_eq!(authors[0].name, "Claude Opus 5 (1M context)");
assert_eq!(authors[0].email, "noreply@anthropic.com");
}
#[test]
fn the_key_is_matched_case_insensitively_and_a_missing_address_is_kept() {
let authors = co_authors("x\n\nco-authored-by: qwen3-8b\nCO-AUTHORED-BY: A B <a@b>\n");
assert_eq!(authors.len(), 2);
assert_eq!(authors[0].name, "qwen3-8b");
assert_eq!(
authors[0].email, "",
"a trailer with no address is still an authorship claim"
);
assert_eq!(authors[1].email, "a@b");
}
#[test]
fn a_human_commit_carries_no_trailer_and_therefore_no_match() {
let message = "fix: correct the off-by-one\n\nNoticed while reading.\n";
assert!(co_authors(message).is_empty());
assert!(
reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty(),
"a human-authored commit must never be harder to review"
);
}
#[test]
fn spelling_differences_that_are_not_identity_differences_are_absorbed() {
assert_eq!(
identity_tokens("Claude Opus 5 (1M context)"),
["claude", "opus", "5"]
);
assert_eq!(identity_tokens("claude-opus-5"), ["claude", "opus", "5"]);
assert_eq!(identity_tokens("qwen3.8-27b"), ["qwen3", "8", "27b"]);
assert!(names_same_model(
"Claude Opus 5 (1M context)",
"claude-opus-5"
));
assert!(
names_same_model("QWEN3_8B", "qwen3-8b"),
"case and separator are spelling, not identity"
);
}
#[test]
fn a_bracketed_qualifier_does_not_make_it_a_different_model() {
assert!(names_same_model(
"Claude Opus 5 (1M context)",
"Claude Opus 5"
));
assert!(names_same_model(
"Claude Opus 5 (200K context)",
"claude opus 5"
));
assert!(
names_same_model("Claude Opus 5 [1M context]", "claude-opus-5"),
"square brackets are dropped too, exactly as the doc comment says"
);
assert_eq!(
identity_tokens("Claude Opus 5 [1M context]"),
["claude", "opus", "5"]
);
for spelling in [
"claude-opus-5",
"claude_opus_5",
"claude.opus.5",
"claude/opus/5",
"claude:opus:5",
"claude+opus+5",
] {
assert_eq!(
identity_tokens(spelling),
["claude", "opus", "5"],
"{spelling} must normalise like every other spelling"
);
}
}
#[test]
fn a_harness_name_matches_no_model_and_is_therefore_silent() {
for harness in ["Claude Code", "Cursor", "Aider", "GitHub Copilot"] {
assert!(
!names_same_model(harness, "claude-opus-5"),
"{harness} names a harness, and a harness is not a model"
);
assert!(!names_same_model(harness, "qwen3-8b"));
}
}
#[test]
fn a_different_model_is_never_reported_as_the_same_one() {
let message = "x\n\nCo-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n";
assert!(reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty());
assert!(reviewers_own_work(&[message.to_owned()], "qwen3.8-27b").is_empty());
}
#[test]
fn distinct_names_and_matching_commits_are_counted_separately() {
let one = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
let two = "b\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
let three = "c\n\nCo-Authored-By: qwen3-8b <x@y>\n".to_owned();
let human = "d\n\nnobody else\n".to_owned();
let hit = reviewers_own_work(&[one, two, three, human], "qwen3-8b");
assert_eq!(
hit.names,
vec!["Qwen3 8B".to_owned(), "qwen3-8b".to_owned()],
"quoted as written, de-duplicated, first-seen order"
);
assert_eq!(
hit.commits, 3,
"three commits matched; the fourth is human-authored"
);
}
#[test]
fn a_commit_is_counted_once_however_many_trailers_it_carries() {
let message = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\nCo-Authored-By: qwen3-8b <x@y>\n";
let hit = reviewers_own_work(&[message.to_owned()], "qwen3-8b");
assert_eq!(hit.commits, 1);
assert_eq!(hit.names.len(), 2);
}
#[test]
fn a_name_that_normalises_to_nothing_matches_nothing() {
assert!(
!names_same_model("", ""),
"two names that say nothing are not evidence of the same model"
);
assert!(!names_same_model("(1M context)", ""));
}
}