use std::sync::OnceLock;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AgenticMode {
FullAgentic,
IdeAssisted,
None,
}
impl AgenticMode {
pub fn as_str(self) -> &'static str {
match self {
AgenticMode::FullAgentic => "full_agentic",
AgenticMode::IdeAssisted => "ide_assisted",
AgenticMode::None => "none",
}
}
}
impl std::str::FromStr for AgenticMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"full_agentic" => Ok(AgenticMode::FullAgentic),
"ide_assisted" => Ok(AgenticMode::IdeAssisted),
"none" => Ok(AgenticMode::None),
_ => Err(()),
}
}
}
struct AiPatterns {
trailer_line: Regex,
claude: Regex,
copilot: Regex,
cursor: Regex,
generated_with_claude_code: Regex,
x_ai_tokens: Regex,
x_ai_model: Regex,
}
fn is_cursor_match(p: &AiPatterns, trailer_value: &str) -> bool {
if let Some(m) = p.cursor.find(trailer_value) {
if m.as_str().contains('@') {
return true; }
let after = trailer_value.get(m.end()..).unwrap_or("");
!after.starts_with('-') } else {
false
}
}
fn ai_patterns() -> &'static AiPatterns {
static PATTERNS: OnceLock<AiPatterns> = OnceLock::new();
PATTERNS.get_or_init(|| AiPatterns {
trailer_line: Regex::new(r"(?im)^[Cc]o-[Aa]uthored-[Bb]y:\s*(.+)$")
.expect("trailer_line pattern compiles"),
claude: Regex::new(r"(?i)\bclaude\b").expect("claude pattern compiles"),
copilot: Regex::new(r"(?i)\bcopilot\b|GitHub\s+Copilot").expect("copilot pattern compiles"),
cursor: Regex::new(r"(?i)@cursor\.sh|\bCursor\b").expect("cursor pattern compiles"),
generated_with_claude_code: Regex::new(r"(?i)Generated\s+with\s+Claude\s+Code")
.expect("generated_with_claude_code pattern compiles"),
x_ai_tokens: Regex::new(r"(?im)^X-AI-Tokens-(?:In|Out):\s*\d")
.expect("x_ai_tokens pattern compiles"),
x_ai_model: Regex::new(r"(?im)^X-AI-Model:\s*\S").expect("x_ai_model pattern compiles"),
})
}
pub fn detect_ai_tool(message: &str) -> Option<&'static str> {
let p = ai_patterns();
for caps in p.trailer_line.captures_iter(message) {
let trailer_value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
if p.claude.is_match(trailer_value) {
return Some("claude");
}
if p.copilot.is_match(trailer_value) {
return Some("copilot");
}
if is_cursor_match(p, trailer_value) {
return Some("cursor");
}
}
None
}
pub fn detect_agentic_mode(message: &str) -> AgenticMode {
let p = ai_patterns();
let mut has_ide = false;
for caps in p.trailer_line.captures_iter(message) {
let trailer_value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
if p.claude.is_match(trailer_value) {
return AgenticMode::FullAgentic;
}
if p.copilot.is_match(trailer_value) || is_cursor_match(p, trailer_value) {
has_ide = true;
}
}
if p.generated_with_claude_code.is_match(message) {
return AgenticMode::FullAgentic;
}
if p.x_ai_tokens.is_match(message) || p.x_ai_model.is_match(message) {
return AgenticMode::FullAgentic;
}
if has_ide {
return AgenticMode::IdeAssisted;
}
AgenticMode::None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ai_patterns_compile() {
let _ = ai_patterns();
}
#[test]
fn detect_ai_tool_detects_claude() {
let msg =
"feat: add auth\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>";
assert_eq!(detect_ai_tool(msg), Some("claude"));
}
#[test]
fn detect_ai_tool_case_insensitive_key() {
let msg = "fix: bug\n\nco-authored-by: Claude Sonnet 4 <noreply@anthropic.com>";
assert_eq!(detect_ai_tool(msg), Some("claude"));
}
#[test]
fn detect_ai_tool_detects_copilot() {
let msg = "feat: autocomplete\n\nCo-Authored-By: GitHub Copilot <copilot@github.com>";
assert_eq!(detect_ai_tool(msg), Some("copilot"));
}
#[test]
fn detect_ai_tool_detects_copilot_bare() {
let msg = "fix: npe\n\nCo-Authored-By: copilot <noreply@github.com>";
assert_eq!(detect_ai_tool(msg), Some("copilot"));
}
#[test]
fn detect_ai_tool_detects_cursor() {
let msg = "chore: refactor\n\nCo-Authored-By: Cursor <noreply@cursor.sh>";
assert_eq!(detect_ai_tool(msg), Some("cursor"));
}
#[test]
fn detect_ai_tool_returns_none_for_human() {
let msg = "feat: auth\n\nCo-Authored-By: Alice Smith <alice@example.com>";
assert_eq!(detect_ai_tool(msg), None);
}
#[test]
fn detect_ai_tool_returns_none_for_no_trailer() {
assert_eq!(detect_ai_tool("feat: add feature"), None);
assert_eq!(detect_ai_tool(""), None);
}
#[test]
fn detect_ai_tool_priority_claude_before_copilot() {
let msg = "pair session\n\n\
Co-Authored-By: Claude Opus <noreply@anthropic.com>\n\
Co-Authored-By: GitHub Copilot <copilot@github.com>";
assert_eq!(detect_ai_tool(msg), Some("claude"));
}
#[test]
fn detect_ai_tool_priority_copilot_before_cursor() {
let msg = "pair session\n\n\
Co-Authored-By: GitHub Copilot <copilot@github.com>\n\
Co-Authored-By: Cursor <noreply@cursor.sh>";
assert_eq!(detect_ai_tool(msg), Some("copilot"));
}
#[test]
fn agentic_mode_as_str() {
assert_eq!(AgenticMode::FullAgentic.as_str(), "full_agentic");
assert_eq!(AgenticMode::IdeAssisted.as_str(), "ide_assisted");
assert_eq!(AgenticMode::None.as_str(), "none");
}
#[test]
fn agentic_mode_from_str_round_trips() {
use std::str::FromStr;
assert_eq!(
AgenticMode::from_str("full_agentic"),
Ok(AgenticMode::FullAgentic)
);
assert_eq!(
AgenticMode::from_str("ide_assisted"),
Ok(AgenticMode::IdeAssisted)
);
assert_eq!(AgenticMode::from_str("none"), Ok(AgenticMode::None));
assert!(AgenticMode::from_str("unknown_value").is_err());
assert!(AgenticMode::from_str("").is_err());
}
#[test]
fn detect_agentic_mode_claude_coauthor_is_full_agentic() {
let msg = "feat: add feature\n\n\
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
#[test]
fn detect_agentic_mode_generated_with_claude_code_is_full_agentic() {
let msg = "fix: resolve timeout\n\n\
🤖 Generated with [Claude Code](https://claude.ai/claude-code)\n\
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
#[test]
fn detect_agentic_mode_generated_body_only_is_full_agentic() {
let msg = "chore: update deps\n\nGenerated with Claude Code";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
#[test]
fn detect_agentic_mode_x_ai_tokens_is_full_agentic() {
let msg = "feat: implement search\n\n\
X-AI-Tokens-In: 1234\n\
X-AI-Tokens-Out: 5678";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
#[test]
fn detect_agentic_mode_x_ai_model_is_full_agentic() {
let msg = "refactor: extract helper\n\nX-AI-Model: claude-sonnet-4-6";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
#[test]
fn detect_agentic_mode_cursor_is_ide_assisted() {
let msg = "fix: null check\n\nCo-Authored-By: Cursor <noreply@cursor.sh>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::IdeAssisted);
}
#[test]
fn detect_agentic_mode_copilot_is_ide_assisted() {
let msg = "feat: autocomplete\n\nCo-Authored-By: GitHub Copilot <copilot@github.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::IdeAssisted);
}
#[test]
fn detect_agentic_mode_copilot_bare_is_ide_assisted() {
let msg = "fix: npe\n\nCo-Authored-By: copilot <noreply@github.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::IdeAssisted);
}
#[test]
fn detect_agentic_mode_plain_commit_is_none() {
assert_eq!(detect_agentic_mode("feat: add button"), AgenticMode::None);
assert_eq!(detect_agentic_mode(""), AgenticMode::None);
}
#[test]
fn detect_agentic_mode_human_coauthor_is_none() {
let msg = "feat: pair program\n\nCo-Authored-By: Alice Smith <alice@example.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::None);
}
#[test]
fn detect_agentic_mode_cursor_in_human_name_is_not_ide_assisted() {
let msg = "feat: auth\n\nCo-Authored-By: Alice Cursor-Williams <alice@example.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::None);
assert_eq!(detect_ai_tool(msg), None);
}
#[test]
fn is_cursor_match_email_domain_form() {
let msg = "fix: npe\n\nCo-Authored-By: AI Bot <ai@cursor.sh>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::IdeAssisted);
assert_eq!(detect_ai_tool(msg), Some("cursor"));
}
#[test]
fn detect_agentic_mode_claude_wins_over_cursor() {
let msg = "pair: fix auth\n\n\
Co-Authored-By: Cursor <noreply@cursor.sh>\n\
Co-Authored-By: Claude Opus <noreply@anthropic.com>";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
}
}