use std::sync::OnceLock;
use regex::Regex;
use crate::collect::ai_attribution::AgenticMode;
#[derive(Debug, Clone, Copy)]
pub struct CommitSignals<'a> {
pub message: &'a str,
pub author_email: &'a str,
pub committer_email: &'a str,
}
impl<'a> CommitSignals<'a> {
pub fn from_message(message: &'a str) -> Self {
Self {
message,
author_email: "",
committer_email: "",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Detection {
pub tool: Option<&'static str>,
pub mode: AgenticMode,
}
pub fn detect(signals: &CommitSignals<'_>) -> Detection {
let trailers: Vec<&str> = trailer_line()
.captures_iter(signals.message)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let mut ide: Option<&'static str> = None;
for marker in markers() {
if !marker.matches(signals, &trailers) {
continue;
}
match marker.mode {
AgenticMode::FullAgentic => {
return Detection {
tool: Some(marker.tool),
mode: AgenticMode::FullAgentic,
};
}
AgenticMode::IdeAssisted => {
if ide.is_none() {
ide = Some(marker.tool);
}
}
AgenticMode::None => {}
}
}
match ide {
Some(tool) => Detection {
tool: Some(tool),
mode: AgenticMode::IdeAssisted,
},
None => Detection {
tool: None,
mode: AgenticMode::None,
},
}
}
pub fn detection_disclosure() -> String {
let mut tools: Vec<&str> = Vec::new();
for m in markers() {
if !tools.contains(&m.tool) {
tools.push(m.tool);
}
}
format!(
"agentic detection: {} marker(s) active for [{}]; detection is marker-based only — \
commits whose trailers or footers were stripped, squashed, or rewritten are \
indistinguishable from human commits, so a low agentic share means \"no markers \
emitted\", not \"no AI assistance\"",
BUILTIN.len(),
tools.join(", ")
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkerScope {
Trailer,
Message,
Email,
}
#[derive(Debug)]
struct AiMarker {
tool: &'static str,
mode: AgenticMode,
scope: MarkerScope,
pattern: Regex,
}
impl AiMarker {
fn matches(&self, signals: &CommitSignals<'_>, trailers: &[&str]) -> bool {
match self.scope {
MarkerScope::Trailer => trailers.iter().any(|v| self.pattern.is_match(v)),
MarkerScope::Message => self.pattern.is_match(signals.message),
MarkerScope::Email => {
self.pattern.is_match(signals.author_email)
|| self.pattern.is_match(signals.committer_email)
}
}
}
}
struct BuiltinSpec {
tool: &'static str,
mode: AgenticMode,
scope: MarkerScope,
pattern: &'static str,
}
const BUILTIN: &[BuiltinSpec] = &[
BuiltinSpec {
tool: "claude",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Trailer,
pattern: r"(?i)\bclaude\b",
},
BuiltinSpec {
tool: "claude",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Message,
pattern: r"(?i)Generated\s+with\s+Claude\s+Code",
},
BuiltinSpec {
tool: "claude",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Message,
pattern: r"(?im)^X-AI-Tokens-(?:In|Out):\s*\d",
},
BuiltinSpec {
tool: "claude",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Message,
pattern: r"(?im)^X-AI-Model:\s*\S",
},
BuiltinSpec {
tool: "trusty-mpm",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Message,
pattern: r"(?i)Generated\s+with\s+trusty-mpm",
},
BuiltinSpec {
tool: "devin",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Trailer,
pattern: r"(?i)\bdevin-ai-integration\b|@devin\.ai\b",
},
BuiltinSpec {
tool: "devin",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Email,
pattern: r"(?i)^devin-ai-integration(\[bot\])?@",
},
BuiltinSpec {
tool: "openhands",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Trailer,
pattern: r"(?i)\bopenhands\b",
},
BuiltinSpec {
tool: "openhands",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Email,
pattern: r"(?i)^openhands@all-hands\.dev$",
},
BuiltinSpec {
tool: "aider",
mode: AgenticMode::FullAgentic,
scope: MarkerScope::Trailer,
pattern: r"(?i)\baider\b",
},
BuiltinSpec {
tool: "copilot",
mode: AgenticMode::IdeAssisted,
scope: MarkerScope::Trailer,
pattern: r"(?i)\bcopilot\b|GitHub\s+Copilot",
},
BuiltinSpec {
tool: "cursor",
mode: AgenticMode::IdeAssisted,
scope: MarkerScope::Trailer,
pattern: r"(?i)@cursor\.sh|\bcursor\b($|[^-\w])",
},
];
fn trailer_line() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?im)^[Cc]o-[Aa]uthored-[Bb]y:\s*(.+)$")
.expect("trailer_line pattern compiles")
})
}
fn markers() -> &'static [AiMarker] {
static SET: OnceLock<Vec<AiMarker>> = OnceLock::new();
SET.get_or_init(|| {
BUILTIN
.iter()
.map(|s| AiMarker {
tool: s.tool,
mode: s.mode,
scope: s.scope,
pattern: Regex::new(s.pattern).expect("builtin marker pattern compiles"),
})
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn mode_of(msg: &str) -> AgenticMode {
detect(&CommitSignals::from_message(msg)).mode
}
#[test]
fn every_builtin_pattern_compiles() {
assert_eq!(markers().len(), BUILTIN.len());
}
#[test]
fn detects_trusty_mpm_footer() {
let msg = "feat: add thing\n\n🤖🤖🤖 Generated with trusty-mpm — \
https://github.com/bobmatnyc/trusty-tools";
let d = detect(&CommitSignals::from_message(msg));
assert_eq!(d.mode, AgenticMode::FullAgentic);
assert_eq!(d.tool, Some("trusty-mpm"));
}
#[test]
fn detects_openhands_coauthor() {
let msg = "Fix runtime startup\n\n\
Co-authored-by: openhands <openhands@all-hands.dev>";
let d = detect(&CommitSignals::from_message(msg));
assert_eq!(d.mode, AgenticMode::FullAgentic);
assert_eq!(d.tool, Some("openhands"));
}
#[test]
fn detects_devin_and_aider() {
assert_eq!(
mode_of(
"chore: bump\n\nCo-authored-by: Devin AI \
<devin-ai-integration[bot]@users.noreply.github.com>"
),
AgenticMode::FullAgentic
);
assert_eq!(
mode_of("refactor: extract\n\nCo-authored-by: aider (gpt-4o) <noreply@aider.chat>"),
AgenticMode::FullAgentic
);
}
#[test]
fn human_named_devin_is_not_agentic() {
let msg = "feat: scoring\n\nCo-authored-by: Devin Booker <devin@personal.example>";
let d = detect(&CommitSignals::from_message(msg));
assert_eq!(d.mode, AgenticMode::None, "a first name is not a marker");
assert_eq!(d.tool, None);
assert_eq!(
mode_of("fix: x\n\nCo-authored-by: Devin <devin@devin.ai>"),
AgenticMode::FullAgentic
);
}
#[test]
fn email_scope_matches_author_or_committer() {
let by_author = CommitSignals {
message: "fix: something",
author_email: "openhands@all-hands.dev",
committer_email: "human@example.com",
};
assert_eq!(detect(&by_author).mode, AgenticMode::FullAgentic);
let by_committer = CommitSignals {
message: "fix: something",
author_email: "human@example.com",
committer_email: "devin-ai-integration[bot]@users.noreply.github.com",
};
assert_eq!(detect(&by_committer).mode, AgenticMode::FullAgentic);
}
#[test]
fn vendor_domain_alone_is_not_agentic() {
let human = CommitSignals {
message: "fix: something",
author_email: "engineer@all-hands.dev",
committer_email: "engineer@all-hands.dev",
};
assert_eq!(detect(&human).mode, AgenticMode::None);
}
#[test]
fn full_agentic_wins_over_ide_assisted() {
let msg = "pair: fix auth\n\n\
Co-Authored-By: Cursor <noreply@cursor.sh>\n\
Co-Authored-By: Claude Opus <noreply@anthropic.com>";
let d = detect(&CommitSignals::from_message(msg));
assert_eq!(d.mode, AgenticMode::FullAgentic);
assert_eq!(d.tool, Some("claude"));
}
#[test]
fn trailer_scope_does_not_match_body_prose() {
assert_eq!(
mode_of("fix: rewrite the openhands adapter, no trailer here"),
AgenticMode::None
);
assert_eq!(
mode_of("fix: x\n\nCo-authored-by: openhands <openhands@all-hands.dev>"),
AgenticMode::FullAgentic
);
}
#[test]
fn disclosure_names_active_tools() {
let d = detection_disclosure();
assert!(d.contains("claude"), "{d}");
assert!(d.contains("trusty-mpm"), "{d}");
assert!(d.contains("no markers emitted"), "{d}");
}
}