rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Pure polars transforms over the library's canonical frames.
//!
//! Two submodules:
//! - [`activity`] — reports driven by `commits` + `file_changes`.
//! - [`blame`] — reports driven by `blame` / `blame_over_time`.
//!
//! Both submodules' public functions are also re-exported at this
//! module's root for ergonomic use.

use polars::prelude::*;

use crate::options::{ActivityOptions, Aggregation, FileSelection};

pub mod activity;
pub mod blame;

pub use activity::{author_report, file_author_matrix, file_report, summary};
pub use blame::{blame_report, blame_timeline_report, blame_timeline_report_with_label};

/// Filter `file_changes` rows according to a [`FileSelection`].
///
/// In Phase 1 this drops generated and vendored rows by default; the
/// `include_lockfiles` field of `FileSelection` is reserved for the
/// lockfile post-filter follow-up and has no effect here yet.
pub fn filter_files(lf: LazyFrame, sel: FileSelection) -> LazyFrame {
    let mut out = lf;
    if !sel.include_generated {
        out = out.filter(col("is_generated").not());
    }
    if !sel.include_vendored {
        out = out.filter(col("is_vendored").not());
    }
    out
}

/// Drop rows whose canonical identity (author or committer, depending
/// on `agg.aggregate`) ends with the GitHub `[bot]` suffix.
///
/// When `activity.ignore_bots` is `false` the input is passed through
/// unchanged. The match is exact-suffix, case-sensitive: a
/// `canonical_<aggregate>_name` ending in the literal `[bot]`
/// substring (e.g. `dependabot[bot]`, `renovate[bot]`,
/// `github-actions[bot]`) is dropped.
///
/// The column inspected is always `canonical_<aggregate>_name` — the
/// `[bot]` suffix lives on the name, never the email.
pub fn filter_bots(lf: LazyFrame, agg: Aggregation, activity: ActivityOptions) -> LazyFrame {
    if !activity.ignore_bots {
        return lf;
    }
    let name_col = format!("canonical_{}_name", agg.aggregate.as_str());
    lf.filter(col(&name_col).str().ends_with(lit("[bot]")).not())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::options::{Aggregate, Identify};

    fn fixture() -> DataFrame {
        df! {
            "canonical_author_name" => ["alice", "dependabot[bot]", "bob"],
            "canonical_committer_name" => ["alice", "alice", "renovate[bot]"],
            "lines" => [10u32, 20, 30],
        }
        .unwrap()
    }

    fn agg(a: Aggregate) -> Aggregation {
        Aggregation {
            aggregate: a,
            identify: Identify::Name,
        }
    }

    fn opts(ignore_bots: bool) -> ActivityOptions {
        ActivityOptions {
            ignore_bots,
            ..ActivityOptions::default()
        }
    }

    #[test]
    fn passthrough_when_ignore_bots_is_false() {
        let df = filter_bots(fixture().lazy(), agg(Aggregate::Author), opts(false))
            .collect()
            .unwrap();
        assert_eq!(df.height(), 3);
    }

    #[test]
    fn drops_author_bots_when_aggregate_is_author() {
        let df = filter_bots(fixture().lazy(), agg(Aggregate::Author), opts(true))
            .collect()
            .unwrap();
        // dependabot[bot] row is dropped; alice and bob survive (note that
        // committer-side renovate[bot] does NOT trigger removal here).
        assert_eq!(df.height(), 2);
        let names: Vec<&str> = df
            .column("canonical_author_name")
            .unwrap()
            .str()
            .unwrap()
            .iter()
            .map(|v| v.unwrap())
            .collect();
        assert!(names.contains(&"alice"));
        assert!(names.contains(&"bob"));
        assert!(!names.contains(&"dependabot[bot]"));
    }

    #[test]
    fn drops_committer_bots_when_aggregate_is_committer() {
        let df = filter_bots(fixture().lazy(), agg(Aggregate::Committer), opts(true))
            .collect()
            .unwrap();
        // renovate[bot] (committer side) is dropped; alice/alice survive
        // (note that author-side dependabot[bot] does NOT trigger removal).
        assert_eq!(df.height(), 2);
        let names: Vec<&str> = df
            .column("canonical_committer_name")
            .unwrap()
            .str()
            .unwrap()
            .iter()
            .map(|v| v.unwrap())
            .collect();
        assert!(names.iter().filter(|&&n| n == "alice").count() == 2);
        assert!(!names.contains(&"renovate[bot]"));
    }

    #[test]
    fn matches_only_exact_bot_suffix() {
        // Names containing "[bot]" anywhere other than as a suffix do NOT
        // match. A literal `[bot]_helper` author should survive.
        let df = df! {
            "canonical_author_name" => ["alice", "[bot]something", "real[bot]"],
            "canonical_committer_name" => ["alice", "alice", "alice"],
            "lines" => [1u32, 2, 3],
        }
        .unwrap();
        let out = filter_bots(df.lazy(), agg(Aggregate::Author), opts(true))
            .collect()
            .unwrap();
        assert_eq!(out.height(), 2);
        let names: Vec<&str> = out
            .column("canonical_author_name")
            .unwrap()
            .str()
            .unwrap()
            .iter()
            .map(|v| v.unwrap())
            .collect();
        assert!(names.contains(&"alice"));
        assert!(names.contains(&"[bot]something"));
        assert!(!names.contains(&"real[bot]"));
    }
}