[tab] /fast/home/franz.srambical/crowd-pilot-serializer/crates/core/src/git_pull_analysis.rs (rust)
//! Analysis module for detecting "out-of-sync" data caused by git pull operations.
//!
//! The VS Code IDE capture extension invalidates its file cache on git checkout,
//! but NOT on git pull. This means if a user:
//! 1. Visits files (content cached)
//! 2. Runs git pull (files change on disk, but cache not invalidated)
//! 3. Edits those files
//!
//! The reconstructed file state will be incorrect because edits are applied
//! to stale cached content.
//!
//! This module detects such sequences in captured session data.

use std::collections::{HashMap, HashSet};
use std::path::Path;

use serde::{Deserialize, Serialize};

/// Result of analyzing a single session for git pull issues.
#[derive(Debug, Clone, Serialize)]
pub struct SessionAnalysis {
    /// Path to the CSV file analyzed
    pub csv_path: String,
    /// All git pull events detected in this session
    pub git_pulls: Vec<GitPullEvent>,
    /// Files that were visited before any git pull
    pub files_visited_before_pull: HashSet<String>,
    /// Whether this session is potentially corrupted
    pub is_corrupted: bool,
    /// Details about the corruption if any
    pub corruption_details: Option<CorruptionDetails>,
}

/// A detected git pull event.
#[derive(Debug, Clone, Serialize)]
pub struct GitPullEvent {
    /// Sequence number of the terminal_command event
    pub sequence: i64,
    /// The full command string
    pub command: String,
    /// Files reported as changed in the terminal output (if parseable)
    pub changed_files: Vec<String>,
    /// Whether we could parse the output
    pub output_parsed: bool,
}

/// Details about detected corruption.
#[derive(Debug, Clone, Serialize)]
pub struct CorruptionDetails {
    /// Files that were: visited before pull, changed by pull, AND edited after pull
    pub affected_files: Vec<String>,
    /// Number of content events on affected files after the pull
    pub affected_edit_count: usize,
}

/// Overall analysis result across all sessions.
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisResult {
    /// Total number of sessions analyzed
    pub total_sessions: usize,
    /// Sessions with at least one terminal git pull
    pub sessions_with_git_pull: usize,
    /// Sessions detected as potentially corrupted
    pub corrupted_sessions: usize,
    /// Total git pull events detected
    pub total_git_pulls: usize,
    /// Git pulls where we could parse the output for changed files
    pub parseable_git_pulls: usize,
    /// Total affected files across all corrupted sessions
    pub total_affected_files: usize,
    /// Total affected edit events across all corrupted sessions
    pub total_affected_edits: usize,
    /// Percentage of sessions that are corrupted
    pub corruption_rate: f64,
    /// List of corrupted session paths for review
    pub corrupted_session_paths: Vec<String>,
}

/// A row from the CSV file (for analysis purposes).
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CsvRow {
    #[serde(rename = "Sequence")]
    sequence: Option<i64>,
    file: String,
    text: Option<String>,
    #[serde(rename = "Type")]
    event_type: String,
}

/// State machine for tracking events during session analysis.
#[derive(Debug, Default)]
struct AnalysisState {
    /// Files visited (TAB events) - maps file path to first visit sequence
    files_visited: HashMap<String, i64>,
    /// Detected git pull events
    git_pulls: Vec<GitPullEvent>,
    /// Pending terminal command that might be git pull
    pending_git_pull: Option<(i64, String)>,
    /// Buffer for terminal output following a git pull
    terminal_output_buffer: Vec<String>,
    /// Content events after each git pull - maps file path to edit count
    content_events_after_pull: HashMap<String, usize>,
    /// Sequence number of the last git pull (for tracking "after" events)
    last_git_pull_sequence: Option<i64>,
    /// Current sequence number
    current_sequence: i64,
}

impl AnalysisState {
    fn new() -> Self {
        Self::default()
    }

    fn handle_tab_event(&mut self, file: &str, sequence: i64) {
        // Only record the first visit to each file
        self.files_visited.entry(file.to_string()).or_insert(sequence);
    }

    fn handle_terminal_command(&mut self, command: &str, sequence: i64) {
        // First, flush any pending git pull with its accumulated output
        self.flush_pending_git_pull();

        // Check if this is a git pull command
        let command_normalized = command.replace("\
", "
").replace("\
", "
").trim().to_string();

        if is_git_pull_command(&command_normalized) {
            self.pending_git_pull = Some((sequence, command_normalized));
            self.terminal_output_buffer.clear();
        }
    }

    fn handle_terminal_output(&mut self, output: &str) {
        if self.pending_git_pull.is_some() {
            let output_normalized = output.replace("\
", "
").replace("\
", "
");
            self.terminal_output_buffer.push(output_normalized);
        }
    }

    fn handle_content_event(&mut self, file: &str, sequence: i64) {
        // Track content events that happen after a git pull
        if let Some(pull_seq) = self.last_git_pull_sequence {
            if sequence > pull_seq {
                *self.content_events_after_pull.entry(file.to_string()).or_insert(0) += 1;
            }
        }
        self.current_sequence = sequence;
    }

    fn handle_git_branch_checkout(&mut self) {
        // A branch checkout invalidates the cache, so we reset tracking
        // Files visited before checkout don't matter for corruption detection
        // after this point because the cache would be cleared
        self.files_visited.clear();
        self.content_events_after_pull.clear();
        self.last_git_pull_sequence = None;
    }

    fn flush_pending_git_pull(&mut self) {
        if let Some((sequence, command)) = self.pending_git_pull.take() {
            let combined_output = self.terminal_output_buffer.join("");
            let (changed_files, output_parsed) = parse_git_pull_output(&combined_output);

            self.git_pulls.push(GitPullEvent {
                sequence,
                command,
                changed_files,
                output_parsed,
            });

            self.last_git_pull_sequence = Some(sequence);
            self.terminal_output_buffer.clear();
        }
    }

    fn finalize(mut self) -> SessionAnalysis {
        // Flush any remaining pending git pull
        self.flush_pending_git_pull();

        let files_visited_before_pull: HashSet<String> = if let Some(first_pull) = self.git_pulls.first() {
            self.files_visited
                .iter()
                .filter(|(_, &seq)| seq < first_pull.sequence)
                .map(|(path, _)| path.clone())
                .collect()
        } else {
            HashSet::new()
        };

        // Determine corruption: files that were visited before pull AND edited after
        let mut affected_files = Vec::new();
        let mut affected_edit_count = 0;

        // Get all files changed by any git pull
        let files_changed_by_pulls: HashSet<String> = self.git_pulls
            .iter()
            .flat_map(|p| p.changed_files.iter().cloned())
            .collect();

        for (file, edit_count) in &self.content_events_after_pull {
            // File is affected if:
            // 1. It was visited before the pull (in cache)
            // 2. It was changed by the pull (or we couldn't parse output)
            // 3. It was edited after the pull
            let was_visited_before = files_visited_before_pull.contains(file);
            let was_changed_by_pull = files_changed_by_pulls.contains(file)
                || self.git_pulls.iter().any(|p| !p.output_parsed); // Conservative: if we can't parse, assume affected

            if was_visited_before && was_changed_by_pull {
                affected_files.push(file.clone());
                affected_edit_count += edit_count;
            }
        }

        let is_corrupted = !affected_files.is_empty();
        let corruption_details = if is_corrupted {
            Some(CorruptionDetails {
                affected_files: affected_files.clone(),
                affected_edit_count,
            })
        } else {
            None
        };

        SessionAnalysis {
            csv_path: String::new(), // Will be set by caller
            git_pulls: self.git_pulls,
            files_visited_before_pull,
            is_corrupted,
            corruption_details,
        }
    }
}

/// Check if a command is a git pull (or equivalent).
fn is_git_pull_command(command: &str) -> bool {
    let cmd = command.trim().to_lowercase();

    // Direct git pull
    if cmd.starts_with("git pull") {
        return true;
    }

    // git fetch followed by git merge (often in a single command)
    if cmd.contains("git fetch") && cmd.contains("git merge") {
        return true;
    }

    // Common aliases
    if cmd == "gpl" || cmd == "gl" || cmd.starts_with("gpl ") || cmd.starts_with("gl ") {
        return true;
    }

    false
}

/// Parse git pull output to extract changed files.
///
/// Returns (changed_files, was_parseable).
fn parse_git_pull_output(output: &str) -> (Vec<String>, bool) {
    let mut changed_files = Vec::new();
    let mut found_any_pattern = false;

    for line in output.lines() {
        let line = line.trim();

        // Pattern 1: "path/to/file.rs | 10 ++-"  (diff stat format)
        if line.contains('|') && (line.contains('+') || line.contains('-')) {
            if let Some(file_part) = line.split('|').next() {
                let file = file_part.trim();
                if !file.is_empty() && !file.contains("changed") && !file.contains("insertion") {
                    changed_files.push(normalize_file_path(file));
                    found_any_pattern = true;
                }
            }
        }

        // Pattern 2: "create mode 100644 path/to/file.rs"
        if line.starts_with("create mode") || line.starts_with("delete mode") {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 4 {
                changed_files.push(normalize_file_path(parts[3]));
                found_any_pattern = true;
            }
        }

        // Pattern 3: "rename path/to/old.rs => path/to/new.rs"
        if line.starts_with("rename ") && line.contains("=>") {
            if let Some(arrow_pos) = line.find("=>") {
                let new_file = line[arrow_pos + 2..].trim();
                // Handle the {old => new} format
                if let Some(brace_end) = new_file.find('}') {
                    let base = &line[7..line.find('{').unwrap_or(7)];
                    let new_part = &new_file[..brace_end];
                    changed_files.push(normalize_file_path(&format!("{}{}", base.trim(), new_part.trim())));
                } else {
                    changed_files.push(normalize_file_path(new_file));
                }
                found_any_pattern = true;
            }
        }
    }

    // Check for "Already up to date" which means no changes
    if output.contains("Already up to date") || output.contains("Already up-to-date") {
        return (Vec::new(), true);
    }

    // Check for fast-forward indicator
    if output.contains("Fast-forward") && changed_files.is_empty() && !found_any_pattern {
        // Fast-forward happened but we couldn't parse files - mark as unparseable
        return (Vec::new(), false);
    }

    (changed_files, found_any_pattern || output.contains("Already up to date"))
}

/// Normalize a file path for comparison.
fn normalize_file_path(path: &str) -> String {
    // Remove leading ./ if present
    let path = path.strip_prefix("./").unwrap_or(path);
    // Remove any trailing whitespace or special chars
    path.trim().to_string()
}

/// Analyze a single CSV session file for git pull issues.
pub fn analyze_session(csv_path: &Path) -> Result<SessionAnalysis, Box<dyn std::error::Error>> {
    let mut state = AnalysisState::new();
    let mut reader = csv::Reader::from_path(csv_path)?;

    for result in reader.deserialize() {
        let row: CsvRow = result?;
        let sequence = row.sequence.unwrap_or(0);

        match row.event_type.as_str() {
            "tab" => {
                state.handle_tab_event(&row.file, sequence);
            }
            "content" => {
                state.handle_content_event(&row.file, sequence);
            }
            "terminal_command" => {
                if let Some(text) = &row.text {
                    state.handle_terminal_command(text, sequence);
                }
            }
            "terminal_output" => {
                if let Some(text) = &row.text {
                    state.handle_terminal_output(text);
                }
            }
            "git_branch_checkout" => {
                state.handle_git_branch_checkout();
            }
            _ => {}
        }
    }

    let mut analysis = state.finalize();
    analysis.csv_path = csv_path.to_string_lossy().to_string();
    Ok(analysis)
}

/// Analyze all sessions in a directory for git pull issues.
pub fn analyze_all_sessions(root: &Path) -> Result<AnalysisResult, Box<dyn std::error::Error>> {
    use rayon::prelude::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use walkdir::WalkDir;

    // Discover CSV files
    let csv_files: Vec<_> = WalkDir::new(root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map_or(false, |ext| ext == "csv"))
        .map(|e| e.path().to_path_buf())
        .collect();

    if csv_files.is_empty() {
        return Err(format!("No CSV files found under {:?}", root).into());
    }

    let total_files = csv_files.len();
    let processed_count = AtomicUsize::new(0);

    // Process in parallel
    let analyses: Vec<SessionAnalysis> = csv_files
        .into_par_iter()
        .filter_map(|csv_path| {
            let result = analyze_session(&csv_path);
            let count = processed_count.fetch_add(1, Ordering::Relaxed) + 1;

            if count % 100 == 0 || count == total_files {
                eprintln!("Analyzed {}/{} sessions...", count, total_files);
            }

            match result {
                Ok(analysis) => Some(analysis),
                Err(e) => {
                    eprintln!("Error analyzing {:?}: {}", csv_path, e);
                    None
                }
            }
        })
        .collect();

    // Aggregate results
    let total_sessions = analyses.len();
    let sessions_with_git_pull = analyses.iter().filter(|a| !a.git_pulls.is_empty()).count();
    let corrupted_sessions = analyses.iter().filter(|a| a.is_corrupted).count();
    let total_git_pulls: usize = analyses.iter().map(|a| a.git_pulls.len()).sum();
    let parseable_git_pulls: usize = analyses
        .iter()
        .flat_map(|a| a.git_pulls.iter())
        .filter(|p| p.output_parsed)
        .count();

    let total_affected_files: usize = analyses
        .iter()
        .filter_map(|a| a.corruption_details.as_ref())
        .map(|d| d.affected_files.len())
        .sum();

    let total_affected_edits: usize = analyses
        .iter()
        .filter_map(|a| a.corruption_details.as_ref())
        .map(|d| d.affected_edit_count)
        .sum();

    let corrupted_session_paths: Vec<String> = analyses
        .iter()
        .filter(|a| a.is_corrupted)
        .map(|a| a.csv_path.clone())
        .collect();

    let corruption_rate = if total_sessions > 0 {
        corrupted_sessions as f64 / total_sessions as f64
    } else {
        0.0
    };

    Ok(AnalysisResult {
        total_sessions,
        sessions_with_git_pull,
        corrupted_sessions,
        total_git_pulls,
        parseable_git_pulls,
        total_affected_files,
        total_affected_edits,
        corruption_rate,
        corrupted_session_paths,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_git_pull_command() {
        assert!(is_git_pull_command("git pull"));
        assert!(is_git_pull_command("git pull origin main"));
        assert!(is_git_pull_command("git pull --rebase"));
        assert!(is_git_pull_command("  git pull  "));
        assert!(is_git_pull_command("GIT PULL"));

        assert!(!is_git_pull_command("git push"));
        assert!(!is_git_pull_command("git status"));
        assert!(!is_git_pull_command("echo git pull"));
    }

    #[test]
    fn test_parse_git_pull_output_already_up_to_date() {
        let output = "Already up to date.
";
        let (files, parsed) = parse_git_pull_output(output);
        assert!(parsed);
        assert!(files.is_empty());
    }

    #[test]
    fn test_parse_git_pull_output_with_changes() {
        let output = r#"
Updating abc123..def456
Fast-forward
 src/main.rs | 10 +++++-----
 src/lib.rs  |  5 +++++
 2 files changed, 10 insertions(+), 5 deletions(-)
"#;
        let (files, parsed) = parse_git_pull_output(output);
        assert!(parsed);
        assert!(files.contains(&"src/main.rs".to_string()));
        assert!(files.contains(&"src/lib.rs".to_string()));
    }

    #[test]
    fn test_parse_git_pull_output_with_new_file() {
        let output = r#"
Updating abc123..def456
Fast-forward
 src/new_file.rs | 20 ++++++++++++++++++++
 create mode 100644 src/new_file.rs
 1 file changed, 20 insertions(+)
"#;
        let (files, parsed) = parse_git_pull_output(output);
        assert!(parsed);
        assert!(files.contains(&"src/new_file.rs".to_string()));
    }
}