use chrono::{DateTime, Local};
use crate::event::GitEvent;
#[derive(Debug, Clone)]
pub struct AiSession {
pub id: u32,
pub start_time: DateTime<Local>,
pub end_time: DateTime<Local>,
pub commits: Vec<String>,
pub tool: Option<String>,
pub stats: SessionStats,
}
#[derive(Debug, Clone, Default)]
pub struct SessionStats {
pub commit_count: usize,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
const AI_PATTERNS: &[(&str, &str)] = &[
("co-authored-by: claude", "Claude"),
("generated by claude", "Claude"),
("co-authored-by: github copilot", "Copilot"),
("co-authored-by: copilot", "Copilot"),
("generated by copilot", "Copilot"),
("co-authored-by: codex", "Codex"),
("generated by codex", "Codex"),
("co-authored-by: cursor", "Cursor"),
];
const SESSION_GAP_SECONDS: i64 = 300;
pub fn detect_sessions(events: &[GitEvent]) -> Vec<AiSession> {
if events.is_empty() {
return Vec::new();
}
let mut sessions = Vec::new();
let mut current_commits: Vec<(usize, Option<String>)> = Vec::new();
let reversed: Vec<(usize, &GitEvent)> = events.iter().enumerate().rev().collect();
for (idx, event) in &reversed {
let tool = detect_ai_tool(&event.message);
if tool.is_none() {
if current_commits.len() >= 2 {
sessions.push(build_session(
sessions.len() as u32 + 1,
¤t_commits,
events,
));
}
current_commits.clear();
continue;
}
if let Some(&(last_idx, _)) = current_commits.last() {
let last_event = &events[last_idx];
let gap = (event.timestamp - last_event.timestamp).num_seconds().abs();
let same_author = event.author == last_event.author;
if gap > SESSION_GAP_SECONDS || !same_author {
if current_commits.len() >= 2 {
sessions.push(build_session(
sessions.len() as u32 + 1,
¤t_commits,
events,
));
}
current_commits.clear();
}
}
current_commits.push((*idx, tool));
}
if current_commits.len() >= 2 {
sessions.push(build_session(
sessions.len() as u32 + 1,
¤t_commits,
events,
));
}
sessions
}
fn detect_ai_tool(message: &str) -> Option<String> {
let lower = message.to_lowercase();
for &(pattern, tool_name) in AI_PATTERNS {
if lower.contains(pattern) {
return Some(tool_name.to_string());
}
}
None
}
fn build_session(id: u32, commits: &[(usize, Option<String>)], events: &[GitEvent]) -> AiSession {
let commit_hashes: Vec<String> = commits
.iter()
.map(|(idx, _)| events[*idx].short_hash.clone())
.collect();
let tool = commits.iter().find_map(|(_, t)| t.clone());
let timestamps: Vec<DateTime<Local>> = commits
.iter()
.map(|(idx, _)| events[*idx].timestamp)
.collect();
let start_time = match timestamps.iter().copied().min() {
Some(t) => t,
None => {
return AiSession {
id,
start_time: Local::now(),
end_time: Local::now(),
commits: commit_hashes,
tool,
stats: SessionStats::default(),
}
}
};
let end_time = timestamps.iter().copied().max().unwrap_or(start_time);
let stats = SessionStats {
commit_count: commits.len(),
files_changed: commits
.iter()
.map(|(idx, _)| events[*idx].files_added + events[*idx].files_deleted)
.sum(),
insertions: commits
.iter()
.map(|(idx, _)| events[*idx].files_added)
.sum(),
deletions: commits
.iter()
.map(|(idx, _)| events[*idx].files_deleted)
.sum(),
};
AiSession {
id,
start_time,
end_time,
commits: commit_hashes,
tool,
stats,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn make_event(hash: &str, message: &str, author: &str, minutes_ago: i64) -> GitEvent {
GitEvent::commit(
hash.to_string(),
message.to_string(),
author.to_string(),
Local::now() - Duration::minutes(minutes_ago),
3,
1,
)
}
#[test]
fn test_detect_ai_tool_claude() {
assert_eq!(
detect_ai_tool("feat: add login\n\nCo-Authored-By: Claude"),
Some("Claude".to_string())
);
}
#[test]
fn test_detect_ai_tool_copilot() {
assert_eq!(
detect_ai_tool("fix: resolve bug\n\nCo-Authored-By: GitHub Copilot"),
Some("Copilot".to_string())
);
}
#[test]
fn test_detect_ai_tool_codex() {
assert_eq!(
detect_ai_tool("refactor: update\n\nCo-Authored-By: Codex"),
Some("Codex".to_string())
);
}
#[test]
fn test_detect_ai_tool_cursor() {
assert_eq!(
detect_ai_tool("feat: new\n\nCo-Authored-By: Cursor"),
Some("Cursor".to_string())
);
}
#[test]
fn test_detect_ai_tool_generated_by() {
assert_eq!(
detect_ai_tool("feat: login\n\nGenerated by Claude"),
Some("Claude".to_string())
);
}
#[test]
fn test_detect_ai_tool_case_insensitive() {
assert_eq!(
detect_ai_tool("feat: add\n\nco-authored-by: claude"),
Some("Claude".to_string())
);
}
#[test]
fn test_detect_ai_tool_none() {
assert_eq!(detect_ai_tool("fix: simple bug fix"), None);
}
#[test]
fn test_detect_sessions_empty() {
assert!(detect_sessions(&[]).is_empty());
}
#[test]
fn test_detect_sessions_single_ai_commit() {
let events = vec![make_event(
"a1",
"feat: x\n\nCo-Authored-By: Claude",
"alice",
0,
)];
assert!(detect_sessions(&events).is_empty());
}
#[test]
fn test_detect_sessions_two_ai_commits_close() {
let events = vec![
make_event("a2", "feat: b\n\nCo-Authored-By: Claude", "alice", 0),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 2),
];
let sessions = detect_sessions(&events);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].stats.commit_count, 2);
assert_eq!(sessions[0].tool.as_deref(), Some("Claude"));
}
#[test]
fn test_detect_sessions_gap_breaks_session() {
let events = vec![
make_event("a2", "feat: b\n\nCo-Authored-By: Claude", "alice", 0),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 10),
];
let sessions = detect_sessions(&events);
assert!(sessions.is_empty());
}
#[test]
fn test_detect_sessions_different_author_breaks() {
let events = vec![
make_event("a2", "feat: b\n\nCo-Authored-By: Claude", "bob", 0),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 1),
];
let sessions = detect_sessions(&events);
assert!(sessions.is_empty());
}
#[test]
fn test_detect_sessions_non_ai_commit_breaks() {
let events = vec![
make_event("a3", "feat: c\n\nCo-Authored-By: Claude", "alice", 0),
make_event("a2", "manual fix", "alice", 1),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 2),
];
let sessions = detect_sessions(&events);
assert!(sessions.is_empty());
}
#[test]
fn test_detect_sessions_three_commits_one_session() {
let events = vec![
make_event("a3", "feat: c\n\nCo-Authored-By: Claude", "alice", 0),
make_event("a2", "feat: b\n\nCo-Authored-By: Claude", "alice", 2),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 4),
];
let sessions = detect_sessions(&events);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].stats.commit_count, 3);
assert_eq!(sessions[0].id, 1);
}
#[test]
fn test_session_stats_accumulation() {
let events = vec![
make_event("a2", "feat: b\n\nCo-Authored-By: Claude", "alice", 0),
make_event("a1", "feat: a\n\nCo-Authored-By: Claude", "alice", 2),
];
let sessions = detect_sessions(&events);
assert_eq!(sessions[0].stats.commit_count, 2);
assert_eq!(sessions[0].stats.files_changed, 8);
assert_eq!(sessions[0].stats.insertions, 6);
assert_eq!(sessions[0].stats.deletions, 2);
}
#[test]
fn test_session_stats_default() {
let stats = SessionStats::default();
assert_eq!(stats.commit_count, 0);
assert_eq!(stats.files_changed, 0);
}
#[test]
fn test_session_has_commit_hashes() {
let events = vec![
make_event("hash2", "feat: b\n\nCo-Authored-By: Claude", "alice", 0),
make_event("hash1", "feat: a\n\nCo-Authored-By: Claude", "alice", 2),
];
let sessions = detect_sessions(&events);
assert_eq!(sessions[0].commits.len(), 2);
assert!(sessions[0].commits.contains(&"hash1".to_string()));
assert!(sessions[0].commits.contains(&"hash2".to_string()));
}
}