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, Clone, PartialEq, Eq)]
pub enum Rejection {
Lanekeep,
Excluded {
pattern: String,
},
NotIncluded,
}
#[derive(Debug)]
pub struct Discovery {
root: PathBuf,
include: GlobSet,
exclude: GlobSet,
has_include: bool,
exclude_patterns: Vec<String>,
}
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(),
exclude_patterns: exclude.to_vec(),
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn rejects(&self, relative: &FilePath) -> Option<Rejection> {
let path = relative.as_str();
if in_lanekeep_directory(path) {
return Some(Rejection::Lanekeep);
}
if self.exclude.is_match(path)
&& let Some(index) = self.exclude.matches(path).first()
{
return Some(Rejection::Excluded {
pattern: self.exclude_patterns[*index].clone(),
});
}
if self.has_include && !self.include.is_match(path) {
return Some(Rejection::NotIncluded);
}
None
}
#[must_use]
pub fn selects(&self, relative: &FilePath) -> bool {
self.rejects(relative).is_none()
}
#[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 in_lanekeep_directory(relative: &str) -> bool {
relative
.split('/')
.next()
.is_some_and(|first| first == ".lanekeep")
}
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 lanekeeps_own_directory_is_never_selected() {
let fixture = Fixture::new(
"own-directory",
&[
"src/a.ts",
".lanekeep/driver-abc.mjs",
".lanekeep/components/x.wasm",
"src/.lanekeep-notes.ts",
"vendor/.lanekeep/keep.ts",
],
);
let found = fixture.walk(&["**/*"], &[]);
assert!(
!found.iter().any(|p| p.starts_with(".lanekeep/")),
"lanekeep's own directory reached the corpus: {found:?}"
);
assert!(found.contains(&"src/a.ts".to_owned()), "{found:?}");
assert!(
found.contains(&"src/.lanekeep-notes.ts".to_owned()),
"a project file whose name merely begins the same way is a subject: {found:?}"
);
assert!(
found.contains(&"vendor/.lanekeep/keep.ts".to_owned()),
"only the directory at the root is lanekeep's: {found:?}"
);
}
#[test]
fn selects_refuses_lanekeeps_own_directory() {
let fixture = Fixture::new("own-directory-selects", &["src/a.ts"]);
let discovery = Discovery::new(&fixture.dir, &[], &[]).expect("builds");
assert!(!discovery.selects(&FilePath::new(".lanekeep/driver-abc.mjs")));
assert!(discovery.selects(&FilePath::new("src/a.ts")));
}
#[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")));
}
#[test]
fn rejects_names_the_clause_that_would_drop_a_file() {
let fixture = Fixture::new("rejects", &["src/a.ts", "vendor/x.ts"]);
let discovery = Discovery::new(
&fixture.dir,
&["src/**/*.ts".to_owned()],
&["vendor/**".to_owned()],
)
.expect("builds");
assert_eq!(
discovery.rejects(&FilePath::new("src/a.ts")),
None,
"a file the globs select is not rejected"
);
assert_eq!(
discovery.rejects(&FilePath::new("vendor/x.ts")),
Some(Rejection::Excluded {
pattern: "vendor/**".to_owned()
}),
);
assert_eq!(
discovery.rejects(&FilePath::new("other/a.ts")),
Some(Rejection::NotIncluded),
);
assert_eq!(
discovery.rejects(&FilePath::new(".lanekeep/driver.mjs")),
Some(Rejection::Lanekeep),
);
}
}