gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! AI Session detection and visualization
//!
//! Detect sessions where AI tools generated commits, grouping them by temporal proximity
//! and co-author patterns.

use chrono::{DateTime, Local};

use crate::event::GitEvent;

/// Represents an AI-assisted coding session
#[derive(Debug, Clone)]
pub struct AiSession {
    /// Session identifier
    pub id: u32,
    /// Start time of the session
    pub start_time: DateTime<Local>,
    /// End time of the session
    pub end_time: DateTime<Local>,
    /// Commit hashes in this session
    pub commits: Vec<String>,
    /// Detected AI tool name (e.g., "Claude", "Copilot")
    pub tool: Option<String>,
    /// Aggregated statistics
    pub stats: SessionStats,
}

/// Aggregated statistics for an AI session
#[derive(Debug, Clone, Default)]
pub struct SessionStats {
    pub commit_count: usize,
    pub files_changed: usize,
    pub insertions: usize,
    pub deletions: usize,
}

/// AI co-author patterns to detect
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"),
];

/// Maximum gap (in seconds) between commits in the same session
const SESSION_GAP_SECONDS: i64 = 300; // 5 minutes

/// Detect AI-assisted sessions from a list of git events
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(); // (event index, tool)

    // Events are in newest-first order, process in reverse for chronological order
    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() {
            // Non-AI commit breaks the session
            if current_commits.len() >= 2 {
                sessions.push(build_session(
                    sessions.len() as u32 + 1,
                    &current_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();

            // Same author check
            let same_author = event.author == last_event.author;

            if gap > SESSION_GAP_SECONDS || !same_author {
                // Start new session
                if current_commits.len() >= 2 {
                    sessions.push(build_session(
                        sessions.len() as u32 + 1,
                        &current_commits,
                        events,
                    ));
                }
                current_commits.clear();
            }
        }

        current_commits.push((*idx, tool));
    }

    // Handle remaining commits
    if current_commits.len() >= 2 {
        sessions.push(build_session(
            sessions.len() as u32 + 1,
            &current_commits,
            events,
        ));
    }

    sessions
}

/// Detect AI tool from commit message
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
}

/// Build an AiSession from collected commit indices
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() {
        // Need 2+ commits for a session
        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() {
        // Two AI commits within 5 min → 1 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", 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() {
        // Two AI commits > 5 min apart → no session (each has only 1 commit)
        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() {
        // Different authors → separate (each has 1 commit, so no session)
        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() {
        // AI, non-AI, AI → no session (each group has only 1 AI commit)
        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);
        // Each event has files_added=3, files_deleted=1 → files_changed=4 per event
        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()));
    }
}