frigg 0.4.1

Local-first MCP server for code understanding.
Documentation
use std::path::Path;

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

pub(crate) fn build_root_ignore_matcher(root: &Path) -> Gitignore {
    let mut builder = GitignoreBuilder::new(root);
    for ignore_path in [root.join(".gitignore"), root.join(".ignore")] {
        if !ignore_path.is_file() {
            continue;
        }
        if let Some(error) = builder.add(&ignore_path) {
            warn!(
                path = %ignore_path.display(),
                error = %error,
                "could not load workspace ignore rules"
            );
        }
    }

    builder.build().unwrap_or_else(|error| {
        warn!(
            root = %root.display(),
            error = %error,
            "could not compile workspace ignore matcher"
        );
        Gitignore::empty()
    })
}

pub(crate) fn hard_excluded_runtime_path(root: &Path, path: &Path) -> bool {
    let Some(relative) = repository_relative_runtime_path(root, path) else {
        return true;
    };
    let Some(component) = relative.components().next() else {
        return false;
    };
    matches!(
        component.as_os_str().to_string_lossy().as_ref(),
        ".frigg" | ".git" | "target"
    )
}

pub(crate) fn should_ignore_runtime_path(
    root: &Path,
    path: &Path,
    root_ignore_matcher: Option<&Gitignore>,
) -> bool {
    if hard_excluded_runtime_path(root, path) {
        return true;
    }
    let Some(root_ignore_matcher) = root_ignore_matcher else {
        return false;
    };
    let Some(relative) = repository_relative_runtime_path(root, path) else {
        return true;
    };
    root_ignore_matcher
        .matched_path_or_any_parents(relative, false)
        .is_ignore()
}

fn repository_relative_runtime_path<'a>(root: &'a Path, path: &'a Path) -> Option<&'a Path> {
    if path.is_absolute() {
        path.strip_prefix(root).ok()
    } else {
        Some(path)
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::{
        build_root_ignore_matcher, hard_excluded_runtime_path, should_ignore_runtime_path,
    };

    fn unique_root(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time before unix epoch")
            .as_nanos();
        std::env::temp_dir().join(format!("frigg-{prefix}-{nanos}"))
    }

    #[test]
    fn hard_excluded_runtime_path_detects_forbidden_first_component() {
        let root = Path::new("/");

        assert!(hard_excluded_runtime_path(root, Path::new(".git/config")));
        assert!(hard_excluded_runtime_path(
            root,
            Path::new("target/obj/main.o")
        ));
        assert!(!hard_excluded_runtime_path(root, Path::new("src/main.rs")));
        assert!(!hard_excluded_runtime_path(
            root,
            Path::new(".github/workflows/ci.yml")
        ));
    }

    #[test]
    fn should_ignore_runtime_path_applies_gitignore_rules() {
        let root = unique_root("ignore-workspace");
        fs::create_dir_all(&root).expect("temporary root should be created");

        fs::write(root.join(".gitignore"), "ignored/\n").expect(".gitignore should be writable");

        let matcher = build_root_ignore_matcher(&root);
        fs::create_dir_all(root.join("ignored")).expect("ignored dir should be created");

        assert!(should_ignore_runtime_path(
            &root,
            &root.join("ignored/secret.txt"),
            Some(&matcher)
        ));
        assert!(!should_ignore_runtime_path(
            &root,
            &root.join("src/main.rs"),
            Some(&matcher)
        ));

        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn should_ignore_runtime_path_defaults_to_true_for_outside_root_without_matcher() {
        let root = unique_root("ignore-workspace-empty");
        let absolute_path = root.join("src/main.rs");
        let _ = fs::create_dir_all(&root);

        assert!(!should_ignore_runtime_path(&root, &absolute_path, None,));

        let _ = fs::remove_dir_all(root);
    }
}