use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use crate::domain::config::FileFilter;
use crate::errors::{Error, Result};
fn build_set(globs: &[String]) -> Result<Option<GlobSet>> {
if globs.is_empty() {
return Ok(None);
}
let mut b = GlobSetBuilder::new();
for g in globs {
let glob =
Glob::new(g).map_err(|e| Error::InvalidConfig(format!("invalid glob {g:?}: {e}")))?;
b.add(glob);
}
let set = b
.build()
.map_err(|e| Error::InvalidConfig(format!("invalid glob set: {e}")))?;
Ok(Some(set))
}
pub fn resolve(root: &Path, filter: &FileFilter) -> Result<Vec<PathBuf>> {
resolve_scoped(root, root, filter)
}
pub fn resolve_scoped(
glob_root: &Path,
out_root: &Path,
filter: &FileFilter,
) -> Result<Vec<PathBuf>> {
resolve_scoped_excluding(glob_root, out_root, filter, &[], &[])
}
pub fn resolve_scoped_excluding(
glob_root: &Path,
out_root: &Path,
filter: &FileFilter,
scoped_exclude: &[String],
global_exclude: &[String],
) -> Result<Vec<PathBuf>> {
let include = build_set(&filter.include)?;
let exclude = build_set(&filter.exclude)?;
let scoped_ex = build_set(scoped_exclude)?;
let global_ex = build_set(global_exclude)?;
let walk_root = if out_root.starts_with(glob_root) {
out_root
} else if glob_root.starts_with(out_root) {
glob_root
} else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for entry in WalkBuilder::new(walk_root).hidden(false).build() {
let entry =
entry.map_err(|e| Error::Io(format!("walking {}: {e}", walk_root.display())))?;
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
let (Ok(rel_glob), Ok(rel_out)) =
(path.strip_prefix(glob_root), path.strip_prefix(out_root))
else {
continue;
};
let excluded = exclude.as_ref().is_some_and(|e| e.is_match(rel_glob))
|| scoped_ex.as_ref().is_some_and(|e| e.is_match(rel_glob))
|| global_ex.as_ref().is_some_and(|e| e.is_match(rel_out));
let included = include.as_ref().is_none_or(|set| set.is_match(rel_glob));
if included && !excluded {
out.push(rel_out.to_path_buf());
}
}
out.sort();
out.dedup();
Ok(out)
}
pub fn read_text(root: &Path, rel: &Path) -> Result<Option<String>> {
let path = root.join(rel);
let bytes =
std::fs::read(&path).map_err(|e| Error::Io(format!("reading {}: {e}", path.display())))?;
Ok(String::from_utf8(bytes).ok())
}
pub use crate::domain::to_slash;
pub fn from_cli(root: &Path, files: &[PathBuf]) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = files
.iter()
.map(|f| {
f.strip_prefix(root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| f.clone())
})
.collect();
out.sort();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn touch(root: &Path, rel: &str) {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, "x").unwrap();
}
#[test]
fn include_exclude_globs() {
let dir = tempdir().unwrap();
touch(dir.path(), "src/a.rs");
touch(dir.path(), "src/b.rs");
touch(dir.path(), "src/gen.rs");
touch(dir.path(), "README.md");
let filter = FileFilter {
include: vec!["src/**/*.rs".into()],
exclude: vec!["**/gen.rs".into()],
};
let files = resolve(dir.path(), &filter).unwrap();
let names: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(names.iter().any(|n| n.ends_with("a.rs")));
assert!(names.iter().any(|n| n.ends_with("b.rs")));
assert!(!names.iter().any(|n| n.ends_with("gen.rs")));
assert!(!names.iter().any(|n| n.ends_with("README.md")));
}
#[test]
fn double_star_matches_root_level_file() {
let dir = tempdir().unwrap();
touch(dir.path(), "llmlint.yml");
touch(dir.path(), "nested/llmlint.yml");
let filter = FileFilter {
include: vec!["**/llmlint.yml".into()],
exclude: vec![],
};
let files = resolve(dir.path(), &filter).unwrap();
assert_eq!(files.len(), 2, "got {files:?}");
}
#[test]
fn scoped_roots_globs_at_the_config_dir_but_reports_cwd_relative() {
let dir = tempdir().unwrap();
touch(dir.path(), "a/b/c.txt");
touch(dir.path(), "a/b/deep/d.txt"); touch(dir.path(), "a/top.txt"); let filter = FileFilter {
include: vec!["*.txt".into()],
exclude: vec![],
};
let glob_root = dir.path().join("a/b");
let out_root = dir.path().join("a");
let files = resolve_scoped(&glob_root, &out_root, &filter).unwrap();
assert_eq!(
files,
vec![PathBuf::from("b/c.txt"), PathBuf::from("b/deep/d.txt")]
);
}
#[test]
fn scoped_ancestor_glob_root_only_sees_files_under_cwd() {
let dir = tempdir().unwrap();
touch(dir.path(), "proj/src/a.rs");
let filter = FileFilter {
include: vec!["**/*.rs".into()],
exclude: vec![],
};
let glob_root = dir.path().to_path_buf(); let out_root = dir.path().join("proj"); let files = resolve_scoped(&glob_root, &out_root, &filter).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn scoped_non_overlapping_roots_yield_nothing() {
let dir = tempdir().unwrap();
touch(dir.path(), "a/x.txt");
touch(dir.path(), "b/y.txt");
let filter = FileFilter {
include: vec!["**/*.txt".into()],
exclude: vec![],
};
let files = resolve_scoped(&dir.path().join("a"), &dir.path().join("b"), &filter).unwrap();
assert!(files.is_empty());
}
#[test]
fn empty_include_matches_every_file_under_root() {
let dir = tempdir().unwrap();
touch(dir.path(), "src/a.rs");
touch(dir.path(), "README.md");
let files = resolve(dir.path(), &FileFilter::default()).unwrap();
assert_eq!(
files,
vec![PathBuf::from("README.md"), PathBuf::from("src/a.rs")]
);
}
#[test]
fn empty_include_still_honors_exclude() {
let dir = tempdir().unwrap();
touch(dir.path(), "src/a.rs");
touch(dir.path(), "src/gen.rs");
let filter = FileFilter {
include: vec![],
exclude: vec!["**/gen.rs".into()],
};
let files = resolve(dir.path(), &filter).unwrap();
assert_eq!(files, vec![PathBuf::from("src/a.rs")]);
}
#[test]
fn global_exclude_wins_over_a_rule_include() {
let dir = tempdir().unwrap();
touch(dir.path(), "src/a.rs");
touch(dir.path(), "tests/unit.rs");
touch(dir.path(), "tests/fixtures/big.json");
let rule_filter = FileFilter {
include: vec!["**/tests/**".into()],
exclude: vec![],
};
let files = resolve_scoped_excluding(
dir.path(),
dir.path(),
&rule_filter,
&[],
&["tests/fixtures/**".into()],
)
.unwrap();
assert_eq!(files, vec![PathBuf::from("tests/unit.rs")]);
}
#[test]
fn scoped_exclude_wins_over_a_rule_include() {
let dir = tempdir().unwrap();
touch(dir.path(), "tests/unit.rs");
touch(dir.path(), "tests/fixtures/big.json");
let rule_filter = FileFilter {
include: vec!["**/tests/**".into()],
exclude: vec![],
};
let files = resolve_scoped_excluding(
dir.path(),
dir.path(),
&rule_filter,
&["tests/fixtures/**".into()],
&[],
)
.unwrap();
assert_eq!(files, vec![PathBuf::from("tests/unit.rs")]);
}
#[test]
fn global_exclude_matches_cwd_relative_under_a_nested_glob_root() {
let dir = tempdir().unwrap();
touch(dir.path(), "sub/keep.rs");
touch(dir.path(), "sub/vendored/gen.rs");
let rule_filter = FileFilter {
include: vec!["**/*.rs".into()],
exclude: vec![],
};
let glob_root = dir.path().join("sub");
let files = resolve_scoped_excluding(
&glob_root,
dir.path(),
&rule_filter,
&[],
&["sub/vendored/**".into()],
)
.unwrap();
assert_eq!(files, vec![PathBuf::from("sub/keep.rs")]);
}
#[test]
fn invalid_glob_is_reported() {
let filter = FileFilter {
include: vec!["[".into()],
exclude: vec![],
};
assert!(matches!(
resolve(Path::new("."), &filter),
Err(Error::InvalidConfig(_))
));
}
#[test]
fn read_text_reads_utf8_and_skips_binary() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.rs"), "// llmlint: ignore[r] x\n").unwrap();
fs::write(dir.path().join("blob.bin"), [0xff, 0xfe, 0x00]).unwrap();
assert_eq!(
read_text(dir.path(), Path::new("a.rs")).unwrap().as_deref(),
Some("// llmlint: ignore[r] x\n")
);
assert_eq!(read_text(dir.path(), Path::new("blob.bin")).unwrap(), None);
}
#[test]
fn read_text_missing_file_is_an_error() {
let dir = tempdir().unwrap();
assert!(matches!(
read_text(dir.path(), Path::new("nope.rs")),
Err(Error::Io(_))
));
}
#[test]
fn from_cli_relativizes_and_sorts() {
let root = Path::new("/repo");
let files = vec![PathBuf::from("/repo/b.rs"), PathBuf::from("/repo/a.rs")];
let out = from_cli(root, &files);
assert_eq!(out, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
}
}