rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Canonical options vocabulary published by the library.
//!
//! Consumers (CLI, web, future Python bindings) build values of these
//! types and pass them into `rpo::reports::*` (and, in Phase 2,
//! `rpo::Builder` and `rpo::bus_factor`). The library does not derive
//! `clap` or `serde` on these — those are CLI/web concerns. Consumers
//! either keep their own clap-derived enums and convert via `From`, or
//! (future) gate derives behind cargo features.
//!
//! All types are `Copy` so they can be passed by value freely.

/// Whose identity to attribute work to: the `author` (who wrote the
/// change) or the `committer` (who applied it). Maps onto the
/// `canonical_<role>_<id>` columns produced by the library frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Aggregate {
    /// The person who wrote the change.
    #[default]
    Author,
    /// The person who applied it.
    Committer,
}

impl Aggregate {
    /// The lowercase name used in the frames' column names.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Author => "author",
            Self::Committer => "committer",
        }
    }
}

/// Which identity field uniquely names a person: their `name` or their
/// `email`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Identify {
    /// Group by display name.
    #[default]
    Name,
    /// Group by email address.
    Email,
}

impl Identify {
    /// The lowercase name used in the frames' column names.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Name => "name",
            Self::Email => "email",
        }
    }
}

/// How a report groups people: an [`Aggregate`] paired with an
/// [`Identify`]. The default is `(Author, Name)` which matches the
/// CLI's default behavior.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct Aggregation {
    /// Whose identity to attribute work to.
    pub aggregate: Aggregate,
    /// Which field identifies them.
    pub identify: Identify,
}

impl Aggregation {
    /// Build the canonical column name in the library's frames for
    /// this aggregation, e.g. `canonical_author_name`.
    pub fn group_col(&self) -> String {
        format!(
            "canonical_{}_{}",
            self.aggregate.as_str(),
            self.identify.as_str()
        )
    }
}

/// Which categories of files to include in activity reports.
///
/// Defaults exclude generated, vendored, and (once implemented)
/// lockfile rows from reports. In Phase 1 `include_lockfiles` has no
/// effect — the field is reserved for the lockfile post-filter
/// follow-up tracked in BACKLOG.md. Setting it has no observable
/// effect today.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct FileSelection {
    /// Keep files marked `linguist-generated`.
    pub include_generated: bool,
    /// Keep files marked `linguist-vendored`.
    pub include_vendored: bool,
    /// Keep lockfiles. Reserved — no effect yet.
    pub include_lockfiles: bool,
}

/// Walk-time and identity-layer options that govern which commits and
/// touches enter the analysis frames.
///
/// Defaults match git's defaults except for `ignore_merges`, which is
/// `true` (rpo's longstanding default — merge commits inflate stats by
/// double-counting work).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ActivityOptions {
    /// Drop commits with ≥2 parents from the walk. Default `true`.
    pub ignore_merges: bool,
    /// Walk only the first-parent chain. Default `false`. When `true`,
    /// `ignore_merges` is moot — first-parent walks always traverse merge
    /// commits but only follow one parent on each.
    pub first_parent_only: bool,
    /// Ignore whitespace-only changes when computing diff stats.
    /// Default `false` (matches git). Currently a no-op — gix-diff and
    /// gix-blame do not yet expose a whitespace-normalization option;
    /// see `docs/superpowers/specs/2026-04-28-pr2-ignore-whitespace-deferral.md`.
    pub ignore_whitespace: bool,
    /// Drop rows authored or committed by bot accounts (default
    /// deny-list locked in PR 3). Default `false`. PR 3 honours this;
    /// setting it to `true` in PR 1 has no observable effect.
    pub ignore_bots: bool,
}

impl Default for ActivityOptions {
    fn default() -> Self {
        Self {
            ignore_merges: true,
            first_parent_only: false,
            ignore_whitespace: false,
            ignore_bots: false,
        }
    }
}

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

    #[test]
    fn aggregation_group_col_default_is_canonical_author_name() {
        assert_eq!(Aggregation::default().group_col(), "canonical_author_name");
    }

    #[test]
    fn aggregation_group_col_committer_email() {
        let agg = Aggregation {
            aggregate: Aggregate::Committer,
            identify: Identify::Email,
        };
        assert_eq!(agg.group_col(), "canonical_committer_email");
    }

    #[test]
    fn file_selection_default_excludes_all_categories() {
        let sel = FileSelection::default();
        assert!(!sel.include_generated);
        assert!(!sel.include_vendored);
        assert!(!sel.include_lockfiles);
    }

    #[test]
    fn activity_options_default_matches_rpo_defaults() {
        let opts = ActivityOptions::default();
        assert!(opts.ignore_merges, "merges should be excluded by default");
        assert!(!opts.first_parent_only);
        assert!(!opts.ignore_whitespace);
        assert!(!opts.ignore_bots);
    }

    #[test]
    fn activity_options_is_copy() {
        // Copy guarantees by-value passing across the Builder API.
        fn assert_copy<T: Copy>() {}
        assert_copy::<ActivityOptions>();
    }

    #[test]
    fn activity_options_partial_eq_round_trips() {
        let a = ActivityOptions::default();
        let b = ActivityOptions {
            ignore_merges: true,
            first_parent_only: false,
            ignore_whitespace: false,
            ignore_bots: false,
        };
        assert_eq!(a, b);
    }
}