use std::sync::LazyLock;
use regex::bytes::Regex;
use crate::vcs::error::Error;
use crate::vcs::identity::{AuthorId, BotFilter};
static COAUTHOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*co-authored-by:\s*(.*?)\s*<([^>]*)>\s*$")
.expect("COAUTHOR trailer pattern is valid")
});
static TRAILER_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^[A-Za-z0-9][A-Za-z0-9-]*:\s").expect("TRAILER_LINE pattern is valid")
});
const TRAILER_BLOCK_MIN_RATIO: f64 = 0.25;
pub(crate) struct ParticipantResolver<'a> {
mailmap: &'a gix::mailmap::Snapshot,
bots: Option<&'a BotFilter>,
}
impl<'a> ParticipantResolver<'a> {
pub(crate) fn new(mailmap: &'a gix::mailmap::Snapshot, bots: Option<&'a BotFilter>) -> Self {
Self { mailmap, bots }
}
pub(crate) fn participants(&self, commit: &gix::Commit<'_>) -> Result<Vec<AuthorId>, Error> {
let mut out: Vec<AuthorId> = Vec::new();
let author = commit
.author()
.map_err(|e| Error::Walk(format!("decoding commit author: {e}")))?;
let resolved = self.mailmap.resolve(author);
self.push_if_human(&mut out, &resolved.name, &resolved.email);
let message = commit
.message_raw()
.map_err(|e| Error::Walk(format!("decoding commit message: {e}")))?;
let trailer_block = trailer_block(message);
for caps in COAUTHOR.captures_iter(trailer_block) {
let (Some(name), Some(email)) = (caps.get(1), caps.get(2)) else {
continue;
};
self.push_if_human(&mut out, name.as_bytes(), email.as_bytes());
}
Ok(out)
}
fn push_if_human(&self, out: &mut Vec<AuthorId>, name: &[u8], email: &[u8]) {
if let Some(bots) = self.bots
&& bots.is_bot(name, email)
{
return;
}
let id = AuthorId::new(name, email);
if !id.has_identity() {
return;
}
if !out.contains(&id) {
out.push(id);
}
}
}
fn trailer_block(message: &[u8]) -> &[u8] {
let trimmed = trim_trailing_newlines(message);
let block = &trimmed[last_paragraph_start(trimmed)..];
let mut non_blank = 0_usize;
let mut trailer_like = 0_usize;
for line in block.split(|&b| b == b'\n') {
if line.iter().all(u8::is_ascii_whitespace) {
continue;
}
non_blank += 1;
if TRAILER_LINE.is_match(line) {
trailer_like += 1;
}
}
#[allow(clippy::cast_precision_loss)]
let is_trailer_block =
trailer_like > 0 && (trailer_like as f64) >= (non_blank as f64) * TRAILER_BLOCK_MIN_RATIO;
if is_trailer_block { block } else { &[] }
}
fn last_paragraph_start(message: &[u8]) -> usize {
let mut offset = 0_usize;
let mut start = 0_usize;
for line in message.split(|&b| b == b'\n') {
let after_line = offset + line.len() + 1;
if line.iter().all(u8::is_ascii_whitespace) && after_line < message.len() {
start = after_line;
}
offset = after_line;
}
start
}
fn trim_trailing_newlines(message: &[u8]) -> &[u8] {
let end = message
.iter()
.rposition(|&b| b != b'\n' && b != b'\r')
.map_or(0, |i| i + 1);
&message[..end]
}
#[cfg(test)]
#[path = "identity_tests.rs"]
mod tests;