rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Identity canonicalization. Raw (name, email) pairs get mapped to
//! canonical (name, email) pairs using .mailmap and/or a caller-supplied
//! override map. See spec §5.2.

use std::collections::HashMap;

use crate::{RpoError, backend::Signature};

/// Caller-supplied overrides. Applied after mailmap; wins on conflict.
#[derive(Clone, Debug, Default)]
pub struct IdentityMap {
    // Key is (lowercased raw email, or None if the override applies to a
    // raw_name match). Value is (canonical_name, canonical_email).
    by_email: HashMap<String, (String, String)>,
    by_name: HashMap<String, (String, String)>,
}

impl IdentityMap {
    /// An empty map, adding no aliases beyond `.mailmap`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Add an override keyed by raw email (case-insensitive).
    pub fn insert_by_email(
        &mut self,
        raw_email: impl Into<String>,
        canonical_name: impl Into<String>,
        canonical_email: impl Into<String>,
    ) {
        let key = raw_email.into().to_lowercase();
        self.by_email
            .insert(key, (canonical_name.into(), canonical_email.into()));
    }

    /// Add an override keyed by raw name (case-sensitive — names are not
    /// normalized in git).
    pub fn insert_by_name(
        &mut self,
        raw_name: impl Into<String>,
        canonical_name: impl Into<String>,
        canonical_email: impl Into<String>,
    ) {
        self.by_name.insert(
            raw_name.into(),
            (canonical_name.into(), canonical_email.into()),
        );
    }

    fn resolve(&self, raw_name: &str, raw_email: &str) -> Option<(String, String)> {
        if let Some(hit) = self.by_email.get(&raw_email.to_lowercase()) {
            return Some(hit.clone());
        }
        if let Some(hit) = self.by_name.get(raw_name) {
            return Some(hit.clone());
        }
        None
    }
}

/// Combined mailmap + override resolver. Stateless after construction.
pub struct Canonicalizer {
    #[cfg(feature = "backend-gix")]
    mailmap: Option<gix_mailmap::Snapshot>,
    overrides: IdentityMap,
}

impl Canonicalizer {
    pub fn new(mailmap_bytes: Option<&[u8]>, overrides: IdentityMap) -> Result<Self, RpoError> {
        #[cfg(feature = "backend-gix")]
        let mailmap = mailmap_bytes.map(gix_mailmap::Snapshot::from_bytes);
        #[cfg(not(feature = "backend-gix"))]
        let _ = mailmap_bytes;
        Ok(Self {
            #[cfg(feature = "backend-gix")]
            mailmap,
            overrides,
        })
    }

    /// Returns (canonical_name, canonical_email). Falls through to raw values
    /// when no mapping matches.
    pub fn canonicalize(&self, sig: &Signature) -> (String, String) {
        // Spec §5.2: apply mailmap first, then overrides on top so callers
        // can further remap mailmap-resolved identities.
        let mut name = sig.name.clone();
        let mut email = sig.email.clone();

        #[cfg(feature = "backend-gix")]
        if let Some(mm) = &self.mailmap {
            let as_ref = gix::actor::SignatureRef {
                name: name.as_bytes().into(),
                email: email.as_bytes().into(),
                time: "",
            };
            if let Some(resolved) = mm.try_resolve(as_ref) {
                name = resolved.name.to_string();
                email = resolved.email.to_string();
            }
        }

        if let Some((on, oe)) = self.overrides.resolve(&name, &email) {
            name = on;
            email = oe;
        }

        (name, email)
    }
}

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

    fn sig(name: &str, email: &str) -> Signature {
        Signature {
            name: name.into(),
            email: email.into(),
            time_ms: 0,
        }
    }

    #[test]
    fn raw_passthrough_when_no_mapping() {
        let c = Canonicalizer::new(None, IdentityMap::new()).unwrap();
        let (n, e) = c.canonicalize(&sig("Alice", "alice@a.com"));
        assert_eq!(n, "Alice");
        assert_eq!(e, "alice@a.com");
    }

    #[test]
    fn override_by_email_remaps() {
        let mut m = IdentityMap::new();
        m.insert_by_email("old@a.com", "Alice Canonical", "alice@canonical.com");
        let c = Canonicalizer::new(None, m).unwrap();
        let (n, e) = c.canonicalize(&sig("whoever", "OLD@A.COM"));
        assert_eq!(n, "Alice Canonical");
        assert_eq!(e, "alice@canonical.com");
    }

    #[cfg(feature = "backend-gix")]
    #[test]
    fn mailmap_applied() {
        let bytes = b"Alice Real <alice@real.com> <alice@old.com>\n";
        let c = Canonicalizer::new(Some(bytes), IdentityMap::new()).unwrap();
        let (n, e) = c.canonicalize(&sig("Whatever", "alice@old.com"));
        assert_eq!(n, "Alice Real");
        assert_eq!(e, "alice@real.com");
    }

    #[cfg(feature = "backend-gix")]
    #[test]
    fn override_wins_over_mailmap() {
        let bytes = b"Alice Mail <alice@mail.com> <alice@old.com>\n";
        let mut m = IdentityMap::new();
        // Override keyed by the email that mailmap produces.
        m.insert_by_email("alice@mail.com", "Alice Override", "alice@override.com");
        let c = Canonicalizer::new(Some(bytes), m).unwrap();
        let (n, e) = c.canonicalize(&sig("x", "alice@old.com"));
        assert_eq!(n, "Alice Override");
        assert_eq!(e, "alice@override.com");
    }
}