rpo 0.1.0-beta.4

Git contribution analysis: commits, file changes, and per-line authorship over time as polars DataFrames
//! Path filtering. Decides whether a path is considered for file_changes
//! and blame. Combines default exclusions (lockfiles and known generated
//! patterns), .gitattributes linguist annotations (optional), and
//! caller-supplied include/exclude globs.

use std::path::Path;

use globset::{Glob, GlobSet, GlobSetBuilder};

use crate::RpoError;

/// Default patterns excluded from blame unless the caller overrides.
/// Mirrors conventional "don't bus-factor lockfiles" practice.
const DEFAULT_EXCLUDES: &[&str] = &[
    "**/Cargo.lock",
    "**/package-lock.json",
    "**/yarn.lock",
    "**/pnpm-lock.yaml",
    "**/poetry.lock",
    "**/Pipfile.lock",
    "**/uv.lock",
    "**/Gemfile.lock",
    "**/composer.lock",
    "**/go.sum",
];

#[derive(Clone, Debug, Default)]
pub struct LinguistAttrs {
    pub generated: Vec<String>, // glob strings
    pub vendored: Vec<String>,
}

impl LinguistAttrs {
    /// Parse a `.gitattributes` file. Recognizes two patterns:
    ///   <pattern> linguist-generated=true
    ///   <pattern> linguist-vendored=true
    /// Lines that don't match are ignored (gitattributes has many other
    /// attributes we don't care about).
    pub fn parse(bytes: &[u8]) -> Self {
        let text = std::str::from_utf8(bytes).unwrap_or("");
        let mut out = Self::default();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let mut parts = line.split_whitespace();
            let Some(pattern) = parts.next() else {
                continue;
            };
            for attr in parts {
                match attr {
                    "linguist-generated=true" | "linguist-generated" => {
                        out.generated.push(pattern.to_string());
                    }
                    "linguist-vendored=true" | "linguist-vendored" => {
                        out.vendored.push(pattern.to_string());
                    }
                    _ => {}
                }
            }
        }
        out
    }
}

pub struct FilterSet {
    default_excludes: GlobSet,
    user_includes: Option<GlobSet>,
    user_excludes: Option<GlobSet>,
    generated: GlobSet,
    vendored: GlobSet,
    respect_attrs: bool,
}

impl FilterSet {
    pub fn build(
        user_includes: &[String],
        user_excludes: &[String],
        attrs: Option<LinguistAttrs>,
        respect_attrs: bool,
    ) -> Result<Self, RpoError> {
        Ok(Self {
            default_excludes: build_globs(DEFAULT_EXCLUDES.iter().map(|s| s.to_string()))?,
            user_includes: if user_includes.is_empty() {
                None
            } else {
                Some(build_globs(user_includes.iter().cloned())?)
            },
            user_excludes: if user_excludes.is_empty() {
                None
            } else {
                Some(build_globs(user_excludes.iter().cloned())?)
            },
            generated: build_globs(
                attrs
                    .as_ref()
                    .map(|a| a.generated.clone())
                    .unwrap_or_default(),
            )?,
            vendored: build_globs(
                attrs
                    .as_ref()
                    .map(|a| a.vendored.clone())
                    .unwrap_or_default(),
            )?,
            respect_attrs,
        })
    }

    /// For `file_changes`: whether a touched path is recorded at all.
    ///
    /// Only the caller-supplied globs filter here. The default lockfile
    /// excludes and linguist attributes are deliberately *not* applied:
    /// generated/vendored files stay in the frame as annotated rows so
    /// reports can opt into them via `FileSelection`, and lockfiles
    /// remain visible as ordinary activity (see BACKLOG.md).
    pub fn changes_include(&self, path: &Path) -> bool {
        if let Some(ex) = &self.user_excludes
            && ex.is_match(path)
        {
            return false;
        }
        if let Some(inc) = &self.user_includes
            && !inc.is_match(path)
        {
            return false;
        }
        true
    }

    /// For `file_changes`: every recorded file is annotated; is_generated
    /// and is_vendored are annotations, not filters.
    pub fn classify(&self, path: &Path) -> PathClassification {
        let is_generated = self.respect_attrs && self.generated.is_match(path);
        let is_vendored = self.respect_attrs && self.vendored.is_match(path);
        PathClassification {
            is_generated,
            is_vendored,
        }
    }

    /// For blame: filters out paths that should not be blamed. Default
    /// excludes + linguist-generated/vendored (when respected) + user excludes.
    /// User includes, if provided, restrict further.
    pub fn blame_includes(&self, path: &Path) -> bool {
        if self.default_excludes.is_match(path) {
            return false;
        }
        if self.respect_attrs && self.generated.is_match(path) {
            return false;
        }
        if self.respect_attrs && self.vendored.is_match(path) {
            return false;
        }
        if let Some(ex) = &self.user_excludes
            && ex.is_match(path)
        {
            return false;
        }
        if let Some(inc) = &self.user_includes
            && !inc.is_match(path)
        {
            return false;
        }
        true
    }
}

#[derive(Clone, Copy, Debug)]
pub struct PathClassification {
    pub is_generated: bool,
    pub is_vendored: bool,
}

fn build_globs(patterns: impl IntoIterator<Item = String>) -> Result<GlobSet, RpoError> {
    let mut b = GlobSetBuilder::new();
    for p in patterns {
        let glob = Glob::new(&p).map_err(|source| RpoError::InvalidGlob {
            pattern: p.clone(),
            source,
        })?;
        b.add(glob);
    }
    b.build().map_err(|source| RpoError::InvalidGlob {
        pattern: "(set build)".into(),
        source,
    })
}

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

    #[test]
    fn lockfiles_excluded_by_default() {
        let f = FilterSet::build(&[], &[], None, true).unwrap();
        assert!(!f.blame_includes(Path::new("Cargo.lock")));
        assert!(!f.blame_includes(Path::new("app/package-lock.json")));
        assert!(f.blame_includes(Path::new("src/main.rs")));
    }

    #[test]
    fn user_include_restricts() {
        let f = FilterSet::build(&["src/**/*.rs".into()], &[], None, true).unwrap();
        assert!(f.blame_includes(Path::new("src/main.rs")));
        assert!(!f.blame_includes(Path::new("docs/readme.md")));
    }

    #[test]
    fn user_exclude_overrides_include() {
        let f =
            FilterSet::build(&["src/**".into()], &["src/generated/**".into()], None, true).unwrap();
        assert!(f.blame_includes(Path::new("src/lib.rs")));
        assert!(!f.blame_includes(Path::new("src/generated/model.rs")));
    }

    #[test]
    fn linguist_generated_excluded_when_respected() {
        let attrs = LinguistAttrs {
            generated: vec!["**/*.pb.go".into()],
            vendored: vec![],
        };
        let f = FilterSet::build(&[], &[], Some(attrs.clone()), true).unwrap();
        assert!(!f.blame_includes(Path::new("api/foo.pb.go")));

        let f_ignore_attrs = FilterSet::build(&[], &[], Some(attrs), false).unwrap();
        assert!(f_ignore_attrs.blame_includes(Path::new("api/foo.pb.go")));
    }

    #[test]
    fn classify_reports_flags() {
        let attrs = LinguistAttrs {
            generated: vec!["**/*.pb.go".into()],
            vendored: vec!["vendor/**".into()],
        };
        let f = FilterSet::build(&[], &[], Some(attrs), true).unwrap();
        let c = f.classify(Path::new("api/foo.pb.go"));
        assert!(c.is_generated);
        assert!(!c.is_vendored);
        let c2 = f.classify(Path::new("vendor/lib.rs"));
        assert!(!c2.is_generated);
        assert!(c2.is_vendored);
    }

    #[test]
    fn gitattributes_parser_handles_comments_and_blanks() {
        let bytes =
            b"# comment\n\n*.pb.go linguist-generated=true\nvendor/** linguist-vendored=true\n";
        let attrs = LinguistAttrs::parse(bytes);
        assert_eq!(attrs.generated, vec!["*.pb.go".to_string()]);
        assert_eq!(attrs.vendored, vec!["vendor/**".to_string()]);
    }
}