repopilot 0.18.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use super::RepoFacts;
use std::collections::BTreeMap;

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RepoFactsSummary {
    pub total_files: usize,
    pub files_with_language: usize,
    pub total_non_empty_lines: usize,
    pub languages: Vec<LanguageSummary>,
    pub diagnostics_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageSummary {
    pub language: String,
    pub files: usize,
    pub non_empty_lines: usize,
}

pub fn summarize_repo_facts(facts: &RepoFacts) -> RepoFactsSummary {
    let mut language_totals = BTreeMap::<String, (usize, usize)>::new();

    for file in &facts.files {
        if let Some(language) = &file.language {
            let totals = language_totals.entry(language.clone()).or_default();
            totals.0 += 1;
            totals.1 += file.non_empty_lines;
        }
    }

    let mut languages = language_totals
        .into_iter()
        .map(|(language, (files, non_empty_lines))| LanguageSummary {
            language,
            files,
            non_empty_lines,
        })
        .collect::<Vec<_>>();

    languages.sort_by(|left, right| {
        right
            .files
            .cmp(&left.files)
            .then_with(|| right.non_empty_lines.cmp(&left.non_empty_lines))
            .then_with(|| left.language.cmp(&right.language))
    });

    RepoFactsSummary {
        total_files: facts.files.len(),
        files_with_language: facts
            .files
            .iter()
            .filter(|file| file.language.is_some())
            .count(),
        total_non_empty_lines: facts.files.iter().map(|file| file.non_empty_lines).sum(),
        languages,
        diagnostics_count: facts.diagnostics.len(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::facts::{FactConfidence, FactDiagnostic, FactSource, FileFact};
    use std::path::PathBuf;

    fn file(path: &str, language: Option<&str>, non_empty_lines: usize) -> FileFact {
        FileFact {
            path: PathBuf::from(path),
            language: language.map(str::to_string),
            non_empty_lines,
            source: FactSource::Mixed,
            confidence: FactConfidence::High,
        }
    }

    #[test]
    fn empty_repo_facts_have_an_empty_summary() {
        assert_eq!(
            summarize_repo_facts(&RepoFacts::default()),
            RepoFactsSummary::default()
        );
    }

    #[test]
    fn summary_counts_groups_and_sorts_facts_deterministically() {
        let facts = RepoFacts {
            root: PathBuf::from("/repo"),
            files: vec![
                file("src/lib.rs", Some("Rust"), 30),
                file("src/main.rs", Some("Rust"), 20),
                file("app.py", Some("Python"), 40),
                file("test.py", Some("Python"), 5),
                file("main.go", Some("Go"), 100),
                file("Main.java", Some("Java"), 100),
                file("LICENSE", None, 7),
            ],
            diagnostics: vec![
                FactDiagnostic {
                    code: "facts.first".to_string(),
                    message: "first diagnostic".to_string(),
                    path: None,
                },
                FactDiagnostic {
                    code: "facts.second".to_string(),
                    message: "second diagnostic".to_string(),
                    path: Some(PathBuf::from("src/lib.rs")),
                },
            ],
        };

        let summary = summarize_repo_facts(&facts);

        assert_eq!(summary.total_files, 7);
        assert_eq!(summary.files_with_language, 6);
        assert_eq!(summary.total_non_empty_lines, 302);
        assert_eq!(summary.diagnostics_count, 2);
        assert_eq!(
            summary.languages,
            vec![
                LanguageSummary {
                    language: "Rust".to_string(),
                    files: 2,
                    non_empty_lines: 50,
                },
                LanguageSummary {
                    language: "Python".to_string(),
                    files: 2,
                    non_empty_lines: 45,
                },
                LanguageSummary {
                    language: "Go".to_string(),
                    files: 1,
                    non_empty_lines: 100,
                },
                LanguageSummary {
                    language: "Java".to_string(),
                    files: 1,
                    non_empty_lines: 100,
                },
            ]
        );
    }
}