use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SelectedFile {
pub absolute: PathBuf,
pub relative: PathBuf,
pub normalized: String,
pub is_exec: bool,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SelectError {
#[error("invalid exclude pattern {pattern:?}: {message}")]
InvalidExcludePattern { pattern: String, message: String },
#[error("I/O error{}", .path.as_ref().map(|p| format!(" at {}", p.display())).unwrap_or_default())]
Io {
#[source]
source: std::io::Error,
path: Option<PathBuf>,
},
#[error("walk error: {message}")]
Walk { message: String },
}
pub fn select(plugin_dir: &Path, exclude: &[String]) -> Result<Vec<SelectedFile>, SelectError> {
let root = std::fs::canonicalize(plugin_dir).map_err(|source| SelectError::Io {
source,
path: Some(plugin_dir.to_path_buf()),
})?;
let matcher = build_matcher(&root, exclude)?;
let mut out = Vec::new();
let walk = walkdir::WalkDir::new(&root).follow_links(false);
for result in walk {
let entry = result.map_err(|e| {
if e.io_error().is_some() {
let path = e.path().map(|p| p.to_path_buf());
SelectError::Io {
source: e.into_io_error().expect("io_error present"),
path,
}
} else {
SelectError::Walk {
message: e.to_string(),
}
}
})?;
if entry.file_type().is_dir() || !entry.file_type().is_file() {
continue;
}
let absolute = entry.path().to_path_buf();
let relative = absolute
.strip_prefix(&root)
.map_err(|e| SelectError::Walk {
message: format!("path outside plugin_dir: {e}"),
})?
.to_path_buf();
if matcher
.matched_path_or_any_parents(&absolute, false)
.is_ignore()
{
continue;
}
let is_exec = is_executable(&absolute).map_err(|source| SelectError::Io {
source,
path: Some(absolute.clone()),
})?;
let normalized = to_normalized(&relative);
out.push(SelectedFile {
absolute,
relative,
normalized,
is_exec,
});
}
out.sort_by(|a, b| a.normalized.cmp(&b.normalized));
Ok(out)
}
fn build_matcher(root: &Path, exclude: &[String]) -> Result<Gitignore, SelectError> {
if exclude.is_empty() {
return Ok(Gitignore::empty());
}
let mut builder = GitignoreBuilder::new(root);
for pattern in exclude {
builder
.add_line(None, pattern)
.map_err(|e| SelectError::InvalidExcludePattern {
pattern: pattern.clone(),
message: e.to_string(),
})?;
}
builder
.build()
.map_err(|e| SelectError::InvalidExcludePattern {
pattern: exclude.join(", "),
message: e.to_string(),
})
}
fn to_normalized(relative: &Path) -> String {
relative
.components()
.filter_map(|c| match c {
Component::Normal(os) => Some(os.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(unix)]
fn is_executable(path: &Path) -> std::io::Result<bool> {
use std::os::unix::fs::PermissionsExt;
Ok(std::fs::metadata(path)?.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(_path: &Path) -> std::io::Result<bool> {
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(dir: &std::path::Path, rel: &str, body: &str) {
let p = dir.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn empty_exclude_is_a_no_op_selecting_all_regular_files() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "manifest.toml", "x");
write(td.path(), "__init__.py", "y");
write(td.path(), "pkg/helper.py", "z");
let got: Vec<String> = select(td.path(), &[])
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert_eq!(got, vec!["__init__.py", "manifest.toml", "pkg/helper.py"]);
}
#[test]
fn directory_pattern_excludes_nested_files_at_any_depth() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "__init__.py", "y");
write(td.path(), "__pycache__/a/b/c.pyc", "junk");
let got: Vec<String> = select(td.path(), &["__pycache__/".to_string()])
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert!(
!got.iter().any(|p| p.contains("__pycache__")),
"got {got:?}"
);
assert!(got.contains(&"__init__.py".to_string()));
}
#[test]
fn invalid_pattern_names_the_offender() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "__init__.py", "y");
let bad = "[z-a]".to_string();
let err = select(td.path(), std::slice::from_ref(&bad)).unwrap_err();
match err {
SelectError::InvalidExcludePattern { pattern, .. } => assert_eq!(pattern, bad),
other => panic!("expected InvalidExcludePattern, got {other:?}"),
}
}
#[test]
fn output_is_normalized_forward_slash_and_sorted() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "z.py", "");
write(td.path(), "a.py", "");
write(td.path(), "pkg/m.py", "");
let got: Vec<String> = select(td.path(), &[])
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert_eq!(got, vec!["a.py", "pkg/m.py", "z.py"]); assert!(got.iter().all(|p| !p.contains('\\')));
}
#[test]
fn negation_re_includes_a_file_beneath_an_excluded_dir() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "__init__.py", "y");
write(td.path(), "__pycache__/keep.pyc", "keep");
write(td.path(), "__pycache__/drop.pyc", "drop");
let got: Vec<String> = select(
td.path(),
&[
"__pycache__/".to_string(),
"!__pycache__/keep.pyc".to_string(),
],
)
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert!(
got.contains(&"__pycache__/keep.pyc".to_string()),
"got {got:?}"
);
assert!(
!got.contains(&"__pycache__/drop.pyc".to_string()),
"got {got:?}"
);
}
#[cfg(unix)]
#[test]
fn backslash_byte_in_component_does_not_leak_into_normalized_path() {
let p = PathBuf::from("sub\\leaf");
let result = to_normalized(&p);
assert_eq!(result, "sub\\leaf", "single component preserved verbatim");
assert!(
!result.contains('/'),
"single component must not introduce `/`"
);
let p: PathBuf = ["a", "sub\\leaf", "c"].iter().collect();
let result = to_normalized(&p);
assert_eq!(
result, "a/sub\\leaf/c",
"backslash byte inside a component is preserved; `/` only appears as component separator"
);
}
#[test]
fn anchored_pattern_is_relative_to_plugin_root_not_nested_dirs() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "__init__.py", "y");
write(td.path(), "tests/a.py", "root-level tests, excluded");
write(td.path(), "pkg/tests/b.py", "nested tests, kept");
let got: Vec<String> = select(td.path(), &["tests/**".to_string()])
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert!(
!got.iter().any(|p| p == "tests/a.py"),
"root tests/ must be excluded: {got:?}"
);
assert!(
got.iter().any(|p| p == "pkg/tests/b.py"),
"nested pkg/tests/ must be kept (pattern anchored to root): {got:?}"
);
assert!(got.iter().any(|p| p == "__init__.py"), "got {got:?}");
}
#[test]
fn reversed_order_whitelist_then_dir_exclude_is_characterized() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "__init__.py", "y");
write(td.path(), "__pycache__/keep.pyc", "k");
let got: Vec<String> = select(
td.path(),
&[
"!__pycache__/keep.pyc".to_string(),
"__pycache__/".to_string(),
],
)
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert!(
got.contains(&"__pycache__/keep.pyc".to_string()),
"expected keep.pyc present (whitelist short-circuits dir exclude): {got:?}"
);
}
#[cfg(unix)]
#[test]
fn symlinks_and_dirs_are_excluded_from_selection() {
let td = tempfile::tempdir().unwrap();
write(td.path(), "real.py", "");
std::fs::create_dir(td.path().join("subdir")).unwrap();
std::os::unix::fs::symlink(td.path().join("real.py"), td.path().join("link.py")).unwrap();
let got: Vec<String> = select(td.path(), &[])
.unwrap()
.into_iter()
.map(|f| f.normalized)
.collect();
assert_eq!(
got,
vec!["real.py"],
"symlink + dir must be excluded; got {got:?}"
);
}
}