use std::sync::OnceLock;
use regex::Regex;
use crate::collect::ai_markers::{detect, CommitSignals};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AgenticMode {
FullAgentic,
IdeAssisted,
None,
Unknown,
}
impl AgenticMode {
pub fn as_str(self) -> &'static str {
match self {
AgenticMode::FullAgentic => "full_agentic",
AgenticMode::IdeAssisted => "ide_assisted",
AgenticMode::None => "none",
AgenticMode::Unknown => "unknown",
}
}
}
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),
"unknown" => Ok(AgenticMode::Unknown),
_ => Err(()),
}
}
}
pub fn provenance_possibly_stripped(message: &str) -> bool {
let mut lines = message.lines().map(str::trim);
let Some(subject) = lines.next() else {
return false;
};
if machine_merge_subject().is_match(subject) {
return true;
}
if !squash_pr_subject().is_match(subject) {
return false;
}
lines
.filter(|l| !l.is_empty())
.all(|l| trailer_shaped().is_match(l))
}
fn machine_merge_subject() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?i)^Merge (?:(?:remote-tracking )?branch|tag|commit) '|^Merge pull request #\d+ from \S",
)
.expect("machine_merge_subject pattern compiles")
})
}
fn squash_pr_subject() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\(#\d+\)$").expect("squash_pr_subject pattern compiles"))
}
fn trailer_shaped() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"^[A-Za-z][A-Za-z0-9_-]*:\s*\S").expect("trailer_shaped pattern compiles")
})
}
pub fn detect_ai_tool(message: &str) -> Option<&'static str> {
detect(&CommitSignals::from_message(message)).tool
}
pub fn detect_agentic_mode(message: &str) -> AgenticMode {
detect(&CommitSignals::from_message(message)).mode
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_trusty_mpm_footer_is_full_agentic() {
let msg = "docs: add website link to README (#5330)\n\n\
🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
assert_eq!(detect_ai_tool(msg), Some("trusty-mpm"));
}
#[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");
assert_eq!(AgenticMode::Unknown.as_str(), "unknown");
}
#[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_eq!(AgenticMode::from_str("unknown"), Ok(AgenticMode::Unknown));
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 provenance_possibly_stripped_matches_machine_merge_summaries() {
for msg in [
"Merge branch 'feat/x' into main",
"Merge remote-tracking branch 'origin/main'",
"Merge tag 'v2.15.0' into develop",
"Merge commit 'deadbeef'",
"Merge pull request #42 from bobmatnyc/feat-x\n\nfeat: add the thing",
] {
assert!(provenance_possibly_stripped(msg), "{msg}");
}
}
#[test]
fn provenance_possibly_stripped_matches_body_replacing_squash() {
assert!(provenance_possibly_stripped(
"docs: align the corpus (#5397)"
));
assert!(provenance_possibly_stripped(
"ci: scope the check (#5400)\n\n\
Co-authored-by: bobmatnyc <bobmatnyc@users.noreply.github.com>"
));
}
#[test]
fn provenance_possibly_stripped_rejects_authored_messages() {
for msg in [
"Merge branch handling into the parser",
"refactor: merge pull request handling into one module",
"docs: record a rejected proposal (#5379)\n\n\
Docs-only. The linker switch measured slower on this runner.",
"feat: add button",
"",
] {
assert!(!provenance_possibly_stripped(msg), "{msg}");
}
}
#[test]
fn detect_agentic_mode_merge_summary_is_unknown() {
assert_eq!(
detect_agentic_mode("Merge branch 'feat/x' into main"),
AgenticMode::Unknown
);
}
#[test]
fn unknown_is_distinct_from_none() {
assert_ne!(AgenticMode::Unknown, AgenticMode::None);
assert_ne!(AgenticMode::Unknown.as_str(), AgenticMode::None.as_str());
assert_ne!(
detect_agentic_mode("Merge branch 'feat/x' into main"),
detect_agentic_mode("feat: add button")
);
}
#[test]
fn marker_wins_over_rewrite_fingerprint() {
let msg = "Merge branch 'feat/x' into main\n\n\
🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools";
assert_eq!(detect_agentic_mode(msg), AgenticMode::FullAgentic);
assert_eq!(detect_ai_tool(msg), Some("trusty-mpm"));
}
#[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);
}
}