travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
use std::path::Path;

use ignore::gitignore::{Gitignore, GitignoreBuilder};

use crate::model::DiffFile;

/// Apply `.trvignore` rules from the repository root to a diff file set.
///
/// Delegates to [`filter_diff_files_with_matcher`] so both entry points
/// share one filtering implementation; adding a rule-iteration invariant
/// in the future (e.g. re-checking against orphaned paths) only needs to
/// happen in one place.
pub fn filter_diff_files(repo_root: &Path, diff_files: Vec<DiffFile>) -> Vec<DiffFile> {
    filter_diff_files_with_matcher(load_matcher(repo_root).as_ref(), diff_files)
}

/// Build a standalone gitignore-style matcher from an in-memory list of
/// patterns, rooted at `repo_root`. Returns `None` if no patterns were
/// provided or none could be parsed. Exposed so the "blind-to-reviewer"
/// filter (Phase I3) can reuse the same matcher semantics as
/// `.trvignore` without writing to disk first.
#[must_use]
pub fn matcher_from_patterns(repo_root: &Path, patterns: &[String]) -> Option<Gitignore> {
    if patterns.is_empty() {
        return None;
    }
    let mut builder = GitignoreBuilder::new(repo_root);
    let mut any_added = false;
    for pat in patterns {
        if builder.add_line(None, pat).is_ok() {
            any_added = true;
        }
    }
    if !any_added {
        return None;
    }
    builder.build().ok()
}

/// Apply a [`matcher_from_patterns`]-style matcher to a diff file set.
/// Parallel to [`filter_diff_files`] but the rule source is an
/// in-memory pattern list (typically from `review.toml`'s
/// `hidden_from_reviewer`). A `None` matcher means "no blinding
/// configured" and all files pass through unchanged.
pub fn filter_diff_files_with_matcher(
    matcher: Option<&Gitignore>,
    diff_files: Vec<DiffFile>,
) -> Vec<DiffFile> {
    let Some(matcher) = matcher else {
        return diff_files;
    };
    diff_files
        .into_iter()
        .filter(|file| {
            let Some(path) = file.display_path() else {
                return true;
            };
            !is_ignored(matcher, path)
        })
        .collect()
}

/// Build the combined `.gitignore` + `.trvignore` matcher for `repo_root`,
/// or `None` if neither file exists. Exposed so the live watcher (and
/// other consumers) can reuse the same rules used to filter diffs.
#[must_use]
pub fn load_matcher(repo_root: &Path) -> Option<Gitignore> {
    let gitignore_file = repo_root.join(".gitignore");
    let trvignore_file = repo_root.join(".trvignore");

    if !gitignore_file.is_file() && !trvignore_file.is_file() {
        return None;
    }

    let mut builder = GitignoreBuilder::new(repo_root);

    // Load .gitignore first so .trvignore rules can override with `!` patterns.
    if gitignore_file.is_file() {
        let _ = builder.add(&gitignore_file);
    }
    if trvignore_file.is_file() {
        let _ = builder.add(&trvignore_file);
    }

    builder.build().ok()
}

/// Return `true` if `path` is ignored by `matcher`. The `ignore` crate
/// wants a `is_dir` hint; we don't know from a bare path whether it's a
/// file or directory, so we probe both and ignore if either matches.
/// Safe because patterns like `target/` only match directories anyway.
#[must_use]
pub fn is_ignored(matcher: &Gitignore, path: &Path) -> bool {
    matcher.matched_path_or_any_parents(path, false).is_ignore()
        || matcher.matched_path_or_any_parents(path, true).is_ignore()
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;

    use tempfile::tempdir;

    use super::*;
    use crate::model::FileStatus;

    fn make_diff_file(path: &str) -> DiffFile {
        DiffFile {
            old_path: None,
            new_path: Some(PathBuf::from(path)),
            status: FileStatus::Modified,
            hunks: Vec::new(),
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }
    }

    #[test]
    fn keeps_all_files_when_trvignore_is_missing() {
        let dir = tempdir().expect("failed to create temp dir");
        let files = vec![
            make_diff_file("src/main.rs"),
            make_diff_file("target/debug/app"),
        ];

        let filtered = filter_diff_files(dir.path(), files);

        assert_eq!(filtered.len(), 2);
    }

    #[test]
    fn filters_matching_files() {
        let dir = tempdir().expect("failed to create temp dir");
        let ignore_path = dir.path().join(".trvignore");
        fs::write(&ignore_path, "target/\n*.lock\n").expect("failed to write .trvignore");

        let files = vec![
            make_diff_file("src/main.rs"),
            make_diff_file("target/debug/app"),
            make_diff_file("Cargo.lock"),
        ];

        let filtered = filter_diff_files(dir.path(), files);
        let kept_paths: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        assert_eq!(kept_paths, vec!["src/main.rs"]);
    }

    #[test]
    fn supports_unignore_rules() {
        let dir = tempdir().expect("failed to create temp dir");
        let ignore_path = dir.path().join(".trvignore");
        fs::write(&ignore_path, "generated/\n!generated/keep.rs\n")
            .expect("failed to write .trvignore");

        let files = vec![
            make_diff_file("generated/drop.rs"),
            make_diff_file("generated/keep.rs"),
            make_diff_file("src/main.rs"),
        ];

        let filtered = filter_diff_files(dir.path(), files);
        let kept_paths: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        assert_eq!(kept_paths, vec!["generated/keep.rs", "src/main.rs"]);
    }

    #[test]
    fn respects_gitignore() {
        let dir = tempdir().expect("failed to create temp dir");
        let gitignore = dir.path().join(".gitignore");
        fs::write(&gitignore, "target/\n*.log\n").expect("failed to write .gitignore");

        let files = vec![
            make_diff_file("src/main.rs"),
            make_diff_file("target/debug/app"),
            make_diff_file("build.log"),
        ];

        let filtered = filter_diff_files(dir.path(), files);
        let kept: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        assert_eq!(kept, vec!["src/main.rs"]);
    }

    #[test]
    fn trvignore_overrides_gitignore() {
        let dir = tempdir().expect("failed to create temp dir");
        let gitignore = dir.path().join(".gitignore");
        fs::write(&gitignore, "*.lock\n").expect("failed to write .gitignore");
        let trvignore = dir.path().join(".trvignore");
        fs::write(&trvignore, "!Cargo.lock\n").expect("failed to write .trvignore");

        let files = vec![
            make_diff_file("Cargo.lock"),
            make_diff_file("yarn.lock"),
            make_diff_file("src/lib.rs"),
        ];

        let filtered = filter_diff_files(dir.path(), files);
        let kept: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        // Cargo.lock is un-ignored by .trvignore, yarn.lock stays ignored
        assert_eq!(kept, vec!["Cargo.lock", "src/lib.rs"]);
    }

    #[test]
    fn gitignore_alone_filters_without_trvignore() {
        let dir = tempdir().expect("failed to create temp dir");
        let gitignore = dir.path().join(".gitignore");
        fs::write(&gitignore, "dist/\n").expect("failed to write .gitignore");

        let files = vec![
            make_diff_file("src/index.ts"),
            make_diff_file("dist/bundle.js"),
        ];

        let filtered = filter_diff_files(dir.path(), files);
        let kept: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        assert_eq!(kept, vec!["src/index.ts"]);
    }

    #[test]
    fn matcher_from_patterns_hides_matches() {
        let dir = tempdir().expect("failed to create temp dir");
        let matcher = matcher_from_patterns(
            dir.path(),
            &["tests/**".to_string(), "*_test.*".to_string()],
        )
        .expect("matcher should build");

        let files = vec![
            make_diff_file("src/main.rs"),
            make_diff_file("tests/integration.rs"),
            make_diff_file("src/foo_test.rs"),
            make_diff_file("src/bar.rs"),
        ];
        let filtered = filter_diff_files_with_matcher(Some(&matcher), files);
        let kept: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();
        assert_eq!(kept, vec!["src/main.rs", "src/bar.rs"]);
    }

    #[test]
    fn matcher_from_patterns_returns_none_for_empty_input() {
        let dir = tempdir().unwrap();
        assert!(matcher_from_patterns(dir.path(), &[]).is_none());
    }

    #[test]
    fn filter_diff_files_with_matcher_passes_through_when_none() {
        let files = vec![
            make_diff_file("src/main.rs"),
            make_diff_file("tests/integration.rs"),
        ];
        let filtered = filter_diff_files_with_matcher(None, files.clone());
        assert_eq!(filtered.len(), files.len());
    }

    #[test]
    fn handles_deleted_file_paths() {
        let dir = tempdir().expect("failed to create temp dir");
        let ignore_path = dir.path().join(".trvignore");
        fs::write(&ignore_path, "generated/\n").expect("failed to write .trvignore");

        let deleted = DiffFile {
            old_path: Some(PathBuf::from("generated/old.txt")),
            new_path: None,
            status: FileStatus::Deleted,
            hunks: Vec::new(),
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        };
        let kept = make_diff_file("src/lib.rs");

        let filtered = filter_diff_files(dir.path(), vec![deleted, kept]);
        let kept_paths: Vec<String> = filtered
            .iter()
            .map(|f| f.display_path().unwrap().display().to_string())
            .collect();

        assert_eq!(kept_paths, vec!["src/lib.rs"]);
    }
}