use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use thiserror::Error;
use crate::location::FilePath;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum DiscoveryError {
#[error("invalid {field} pattern `{pattern}`: {detail}")]
InvalidGlob {
field: &'static str,
pattern: String,
detail: String,
},
#[error("cannot read project root `{path}`: {detail}")]
Unreadable {
path: String,
detail: String,
},
}
#[derive(Debug)]
pub struct Discovery {
root: PathBuf,
include: GlobSet,
exclude: GlobSet,
has_include: bool,
}
impl Discovery {
pub fn new(
root: impl AsRef<Path>,
include: &[String],
exclude: &[String],
) -> Result<Self, DiscoveryError> {
let root = root.as_ref();
let canonical = root
.canonicalize()
.map_err(|e| DiscoveryError::Unreadable {
path: root.display().to_string(),
detail: e.to_string(),
})?;
Ok(Self {
root: canonical,
include: build_set(include, "include")?,
exclude: build_set(exclude, "exclude")?,
has_include: !include.is_empty(),
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn selects(&self, relative: &FilePath) -> bool {
let path = relative.as_str();
if self.exclude.is_match(path) {
return false;
}
!self.has_include || self.include.is_match(path)
}
#[must_use]
pub fn walk(&self) -> Vec<FilePath> {
let mut out = Vec::new();
for entry in ignore::WalkBuilder::new(&self.root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.parents(true)
.require_git(false)
.build()
{
let Ok(entry) = entry else { continue };
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(relative) = entry.path().strip_prefix(&self.root) else {
continue;
};
let relative = FilePath::new(relative);
if self.selects(&relative) {
out.push(relative);
}
}
out.sort();
out.dedup();
out
}
}
fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
field,
pattern: pattern.clone(),
detail: e.to_string(),
})?;
builder.add(glob);
}
builder.build().map_err(|e| DiscoveryError::InvalidGlob {
field,
pattern: patterns.join(", "),
detail: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new(name: &str, files: &[&str]) -> Self {
let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
let _ = fs::remove_dir_all(&dir);
for path in files {
let full = dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(&full, "const x = 1;\n").expect("writes");
}
fs::create_dir_all(&dir).expect("creates dir");
Self { dir }
}
fn write(&self, path: &str, contents: &str) {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(full, contents).expect("writes");
}
fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
Discovery::new(&self.dir, &include, &exclude)
.expect("builds")
.walk()
.iter()
.map(|p| p.as_str().to_owned())
.collect()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn finds_files_matching_include() {
let fixture = Fixture::new(
"include",
&["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
);
assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
}
#[test]
fn no_include_selects_everything_found() {
let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
let found = fixture.walk(&[], &[]);
assert!(found.contains(&"a.ts".to_owned()));
assert!(found.contains(&"b.md".to_owned()));
}
#[test]
fn exclude_wins_over_include() {
let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
assert_eq!(
fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
["src/a.ts"]
);
}
#[test]
fn respects_gitignore() {
let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
fixture.write(".gitignore", "dist/\n");
let found = fixture.walk(&["**/*.ts"], &[]);
assert!(found.contains(&"src/a.ts".to_owned()));
assert!(
!found.contains(&"dist/b.ts".to_owned()),
"gitignored files must not be checked: {found:?}"
);
}
#[test]
fn the_order_is_sorted_and_stable() {
let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
let first = fixture.walk(&["**/*.ts"], &[]);
assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
for _ in 0..5 {
assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
}
}
#[test]
fn reports_a_bad_glob_with_the_field_it_came_from() {
let fixture = Fixture::new("bad-glob", &["a.ts"]);
let err =
Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
match err {
DiscoveryError::InvalidGlob { field, pattern, .. } => {
assert_eq!(field, "include");
assert_eq!(pattern, "src/[");
}
DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
}
let err =
Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
assert!(
matches!(
err,
DiscoveryError::InvalidGlob {
field: "exclude",
..
}
),
"{err:?}"
);
}
#[test]
fn a_missing_root_is_reported() {
let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
}
#[test]
fn selects_can_be_asked_without_walking() {
let fixture = Fixture::new("selects", &["a.ts"]);
let discovery = Discovery::new(
&fixture.dir,
&["src/**/*.ts".to_owned()],
&["**/*.test.ts".to_owned()],
)
.expect("builds");
assert!(discovery.selects(&FilePath::new("src/a.ts")));
assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
assert!(!discovery.selects(&FilePath::new("other/a.ts")));
}
}