pmat 2.93.1

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeChurnAnalysis {
    pub generated_at: DateTime<Utc>,
    pub period_days: u32,
    pub repository_root: PathBuf,
    pub files: Vec<FileChurnMetrics>,
    pub summary: ChurnSummary,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChurnMetrics {
    pub path: PathBuf,
    pub relative_path: String,
    pub commit_count: usize,
    pub unique_authors: Vec<String>,
    pub additions: usize,
    pub deletions: usize,
    pub churn_score: f32,
    pub last_modified: DateTime<Utc>,
    pub first_seen: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChurnSummary {
    pub total_commits: usize,
    pub total_files_changed: usize,
    pub hotspot_files: Vec<PathBuf>,
    pub stable_files: Vec<PathBuf>,
    pub author_contributions: HashMap<String, usize>,
}

impl FileChurnMetrics {
    /// Calculates a normalized churn score based on commits and changes
    ///
    /// # Examples
    ///
    /// ```rust
    /// use pmat::models::churn::FileChurnMetrics;
    /// use std::path::PathBuf;
    /// use chrono::Utc;
    ///
    /// let mut metrics = FileChurnMetrics {
    ///     path: PathBuf::from("src/main.rs"),
    ///     relative_path: "src/main.rs".to_string(),
    ///     commit_count: 10,
    ///     unique_authors: vec![],
    ///     additions: 200,
    ///     deletions: 100,
    ///     churn_score: 0.0,
    ///     last_modified: Utc::now(),
    ///     first_seen: Utc::now(),
    /// };
    ///
    /// metrics.calculate_churn_score(20, 600);
    /// assert!(metrics.churn_score > 0.0 && metrics.churn_score <= 1.0);
    /// ```
    pub fn calculate_churn_score(&mut self, max_commits: usize, max_changes: usize) {
        let commit_factor = if max_commits > 0 {
            self.commit_count as f32 / max_commits as f32
        } else {
            0.0
        };

        let change_factor = if max_changes > 0 {
            (self.additions + self.deletions) as f32 / max_changes as f32
        } else {
            0.0
        };

        self.churn_score = (commit_factor * 0.6 + change_factor * 0.4).min(1.0);
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, clap::ValueEnum)]
pub enum ChurnOutputFormat {
    Json,
    Markdown,
    Csv,
    Summary,
}

impl std::str::FromStr for ChurnOutputFormat {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "json" => Ok(ChurnOutputFormat::Json),
            "markdown" => Ok(ChurnOutputFormat::Markdown),
            "csv" => Ok(ChurnOutputFormat::Csv),
            "summary" => Ok(ChurnOutputFormat::Summary),
            _ => Err(format!("Invalid output format: {s}")),
        }
    }
}

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

    #[test]
    fn test_file_churn_metrics_calculate_score() {
        let mut metrics = FileChurnMetrics {
            path: PathBuf::from("test.rs"),
            relative_path: "test.rs".to_string(),
            commit_count: 10,
            unique_authors: vec!["author1".to_string()],
            additions: 100,
            deletions: 50,
            churn_score: 0.0,
            last_modified: Utc::now(),
            first_seen: Utc::now(),
        };

        metrics.calculate_churn_score(20, 300);
        assert!(metrics.churn_score > 0.0);
        assert!(metrics.churn_score <= 1.0);

        // Test with max values
        metrics.commit_count = 20;
        metrics.additions = 150;
        metrics.deletions = 150;
        metrics.calculate_churn_score(20, 300);
        assert_eq!(metrics.churn_score, 1.0);
    }

    #[test]
    fn test_file_churn_metrics_zero_max() {
        let mut metrics = FileChurnMetrics {
            path: PathBuf::from("test.rs"),
            relative_path: "test.rs".to_string(),
            commit_count: 10,
            unique_authors: vec![],
            additions: 100,
            deletions: 50,
            churn_score: 0.0,
            last_modified: Utc::now(),
            first_seen: Utc::now(),
        };

        metrics.calculate_churn_score(0, 0);
        assert_eq!(metrics.churn_score, 0.0);
    }

    #[test]
    fn test_churn_output_format_from_str() {
        assert_eq!(
            ChurnOutputFormat::from_str("json").unwrap(),
            ChurnOutputFormat::Json
        );
        assert_eq!(
            ChurnOutputFormat::from_str("JSON").unwrap(),
            ChurnOutputFormat::Json
        );
        assert_eq!(
            ChurnOutputFormat::from_str("markdown").unwrap(),
            ChurnOutputFormat::Markdown
        );
        assert_eq!(
            ChurnOutputFormat::from_str("csv").unwrap(),
            ChurnOutputFormat::Csv
        );
        assert_eq!(
            ChurnOutputFormat::from_str("summary").unwrap(),
            ChurnOutputFormat::Summary
        );

        assert!(ChurnOutputFormat::from_str("invalid").is_err());
    }

    #[test]
    fn test_code_churn_analysis_creation() {
        let analysis = CodeChurnAnalysis {
            generated_at: Utc::now(),
            period_days: 30,
            repository_root: PathBuf::from("/test/repo"),
            files: vec![],
            summary: ChurnSummary {
                total_commits: 100,
                total_files_changed: 50,
                hotspot_files: vec![],
                stable_files: vec![],
                author_contributions: HashMap::new(),
            },
        };

        assert_eq!(analysis.period_days, 30);
        assert_eq!(analysis.summary.total_commits, 100);
        assert_eq!(analysis.summary.total_files_changed, 50);
    }

    #[test]
    fn test_churn_summary_with_data() {
        let mut author_contributions = HashMap::new();
        author_contributions.insert("author1".to_string(), 50);
        author_contributions.insert("author2".to_string(), 30);

        let summary = ChurnSummary {
            total_commits: 80,
            total_files_changed: 25,
            hotspot_files: vec![PathBuf::from("hot1.rs"), PathBuf::from("hot2.rs")],
            stable_files: vec![PathBuf::from("stable1.rs")],
            author_contributions,
        };

        assert_eq!(summary.total_commits, 80);
        assert_eq!(summary.hotspot_files.len(), 2);
        assert_eq!(summary.stable_files.len(), 1);
        assert_eq!(summary.author_contributions.get("author1"), Some(&50));
    }

    #[test]
    fn test_serialization() {
        let metrics = FileChurnMetrics {
            path: PathBuf::from("test.rs"),
            relative_path: "test.rs".to_string(),
            commit_count: 5,
            unique_authors: vec!["dev".to_string()],
            additions: 50,
            deletions: 20,
            churn_score: 0.5,
            last_modified: Utc::now(),
            first_seen: Utc::now(),
        };

        let json = serde_json::to_string(&metrics).unwrap();
        let deserialized: FileChurnMetrics = serde_json::from_str(&json).unwrap();

        assert_eq!(deserialized.commit_count, metrics.commit_count);
        assert_eq!(deserialized.churn_score, metrics.churn_score);
    }
}

#[cfg(test)]
mod property_tests {
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn basic_property_stability(_input in ".*") {
            // Basic property test for coverage
            prop_assert!(true);
        }

        #[test]
        fn module_consistency_check(_x in 0u32..1000) {
            // Module consistency verification
            prop_assert!(_x < 1001);
        }
    }
}