1use std::path::{Path, PathBuf};
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use thiserror::Error;
15
16use crate::location::FilePath;
17
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum DiscoveryError {
21 #[error("invalid {field} pattern `{pattern}`: {detail}")]
23 InvalidGlob {
24 field: &'static str,
26 pattern: String,
28 detail: String,
30 },
31
32 #[error("cannot read project root `{path}`: {detail}")]
34 Unreadable {
35 path: String,
37 detail: String,
39 },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Rejection {
45 Lanekeep,
47 Excluded {
49 pattern: String,
51 },
52 NotIncluded,
54}
55
56#[derive(Debug)]
58pub struct Discovery {
59 root: PathBuf,
60 include: GlobSet,
61 exclude: GlobSet,
62 has_include: bool,
63 exclude_patterns: Vec<String>,
64}
65
66impl Discovery {
67 pub fn new(
74 root: impl AsRef<Path>,
75 include: &[String],
76 exclude: &[String],
77 ) -> Result<Self, DiscoveryError> {
78 let root = root.as_ref();
79 let canonical = root
80 .canonicalize()
81 .map_err(|e| DiscoveryError::Unreadable {
82 path: root.display().to_string(),
83 detail: e.to_string(),
84 })?;
85
86 Ok(Self {
87 root: canonical,
88 include: build_set(include, "include")?,
89 exclude: build_set(exclude, "exclude")?,
90 has_include: !include.is_empty(),
91 exclude_patterns: exclude.to_vec(),
92 })
93 }
94
95 #[must_use]
97 pub fn root(&self) -> &Path {
98 &self.root
99 }
100
101 #[must_use]
113 pub fn rejects(&self, relative: &FilePath) -> Option<Rejection> {
114 let path = relative.as_str();
115 if in_lanekeep_directory(path) {
116 return Some(Rejection::Lanekeep);
117 }
118 if self.exclude.is_match(path)
122 && let Some(index) = self.exclude.matches(path).first()
123 {
124 return Some(Rejection::Excluded {
125 pattern: self.exclude_patterns[*index].clone(),
126 });
127 }
128 if self.has_include && !self.include.is_match(path) {
129 return Some(Rejection::NotIncluded);
130 }
131 None
132 }
133
134 #[must_use]
137 pub fn selects(&self, relative: &FilePath) -> bool {
138 self.rejects(relative).is_none()
139 }
140
141 #[must_use]
146 pub fn walk(&self) -> Vec<FilePath> {
147 let mut out = Vec::new();
148
149 for entry in ignore::WalkBuilder::new(&self.root)
150 .hidden(false)
151 .git_ignore(true)
152 .git_global(true)
153 .git_exclude(true)
154 .parents(true)
155 .require_git(false)
160 .build()
161 {
162 let Ok(entry) = entry else { continue };
164 if !entry.file_type().is_some_and(|t| t.is_file()) {
165 continue;
166 }
167 let Ok(relative) = entry.path().strip_prefix(&self.root) else {
168 continue;
169 };
170
171 let relative = FilePath::new(relative);
172 if self.selects(&relative) {
173 out.push(relative);
174 }
175 }
176
177 out.sort();
178 out.dedup();
179 out
180 }
181}
182
183fn in_lanekeep_directory(relative: &str) -> bool {
197 relative
198 .split('/')
199 .next()
200 .is_some_and(|first| first == ".lanekeep")
201}
202
203fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
204 let mut builder = GlobSetBuilder::new();
205 for pattern in patterns {
206 let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
207 field,
208 pattern: pattern.clone(),
209 detail: e.to_string(),
210 })?;
211 builder.add(glob);
212 }
213 builder.build().map_err(|e| DiscoveryError::InvalidGlob {
214 field,
215 pattern: patterns.join(", "),
216 detail: e.to_string(),
217 })
218}
219
220#[cfg(test)]
221mod tests {
222 use std::fs;
223
224 use super::*;
225
226 struct Fixture {
227 dir: PathBuf,
228 }
229
230 impl Fixture {
231 fn new(name: &str, files: &[&str]) -> Self {
232 let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
233 let _ = fs::remove_dir_all(&dir);
234 for path in files {
235 let full = dir.join(path);
236 if let Some(parent) = full.parent() {
237 fs::create_dir_all(parent).expect("creates parent");
238 }
239 fs::write(&full, "const x = 1;\n").expect("writes");
240 }
241 fs::create_dir_all(&dir).expect("creates dir");
242 Self { dir }
243 }
244
245 fn write(&self, path: &str, contents: &str) {
246 let full = self.dir.join(path);
247 if let Some(parent) = full.parent() {
248 fs::create_dir_all(parent).expect("creates parent");
249 }
250 fs::write(full, contents).expect("writes");
251 }
252
253 fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
254 let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
255 let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
256 Discovery::new(&self.dir, &include, &exclude)
257 .expect("builds")
258 .walk()
259 .iter()
260 .map(|p| p.as_str().to_owned())
261 .collect()
262 }
263 }
264
265 impl Drop for Fixture {
266 fn drop(&mut self) {
267 let _ = fs::remove_dir_all(&self.dir);
268 }
269 }
270
271 #[test]
277 fn lanekeeps_own_directory_is_never_selected() {
278 let fixture = Fixture::new(
279 "own-directory",
280 &[
281 "src/a.ts",
282 ".lanekeep/driver-abc.mjs",
283 ".lanekeep/components/x.wasm",
284 "src/.lanekeep-notes.ts",
287 "vendor/.lanekeep/keep.ts",
288 ],
289 );
290 let found = fixture.walk(&["**/*"], &[]);
291 assert!(
292 !found.iter().any(|p| p.starts_with(".lanekeep/")),
293 "lanekeep's own directory reached the corpus: {found:?}"
294 );
295 assert!(found.contains(&"src/a.ts".to_owned()), "{found:?}");
296 assert!(
297 found.contains(&"src/.lanekeep-notes.ts".to_owned()),
298 "a project file whose name merely begins the same way is a subject: {found:?}"
299 );
300 assert!(
301 found.contains(&"vendor/.lanekeep/keep.ts".to_owned()),
302 "only the directory at the root is lanekeep's: {found:?}"
303 );
304 }
305
306 #[test]
308 fn selects_refuses_lanekeeps_own_directory() {
309 let fixture = Fixture::new("own-directory-selects", &["src/a.ts"]);
310 let discovery = Discovery::new(&fixture.dir, &[], &[]).expect("builds");
311 assert!(!discovery.selects(&FilePath::new(".lanekeep/driver-abc.mjs")));
312 assert!(discovery.selects(&FilePath::new("src/a.ts")));
313 }
314
315 #[test]
316 fn finds_files_matching_include() {
317 let fixture = Fixture::new(
318 "include",
319 &["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
320 );
321 assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
322 }
323
324 #[test]
325 fn no_include_selects_everything_found() {
326 let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
327 let found = fixture.walk(&[], &[]);
328 assert!(found.contains(&"a.ts".to_owned()));
329 assert!(found.contains(&"b.md".to_owned()));
330 }
331
332 #[test]
333 fn exclude_wins_over_include() {
334 let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
337 assert_eq!(
338 fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
339 ["src/a.ts"]
340 );
341 }
342
343 #[test]
344 fn respects_gitignore() {
345 let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
346 fixture.write(".gitignore", "dist/\n");
347
348 let found = fixture.walk(&["**/*.ts"], &[]);
349 assert!(found.contains(&"src/a.ts".to_owned()));
350 assert!(
351 !found.contains(&"dist/b.ts".to_owned()),
352 "gitignored files must not be checked: {found:?}"
353 );
354 }
355
356 #[test]
357 fn the_order_is_sorted_and_stable() {
358 let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
362 let first = fixture.walk(&["**/*.ts"], &[]);
363 assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
364
365 for _ in 0..5 {
366 assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
367 }
368 }
369
370 #[test]
371 fn reports_a_bad_glob_with_the_field_it_came_from() {
372 let fixture = Fixture::new("bad-glob", &["a.ts"]);
373 let err =
374 Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
375
376 match err {
377 DiscoveryError::InvalidGlob { field, pattern, .. } => {
378 assert_eq!(field, "include");
379 assert_eq!(pattern, "src/[");
380 }
381 DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
382 }
383
384 let err =
385 Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
386 assert!(
387 matches!(
388 err,
389 DiscoveryError::InvalidGlob {
390 field: "exclude",
391 ..
392 }
393 ),
394 "{err:?}"
395 );
396 }
397
398 #[test]
399 fn a_missing_root_is_reported() {
400 let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
401 assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
402 }
403
404 #[test]
405 fn selects_can_be_asked_without_walking() {
406 let fixture = Fixture::new("selects", &["a.ts"]);
407 let discovery = Discovery::new(
408 &fixture.dir,
409 &["src/**/*.ts".to_owned()],
410 &["**/*.test.ts".to_owned()],
411 )
412 .expect("builds");
413
414 assert!(discovery.selects(&FilePath::new("src/a.ts")));
415 assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
416 assert!(!discovery.selects(&FilePath::new("other/a.ts")));
417 }
418
419 #[test]
420 fn rejects_names_the_clause_that_would_drop_a_file() {
421 let fixture = Fixture::new("rejects", &["src/a.ts", "vendor/x.ts"]);
422 let discovery = Discovery::new(
423 &fixture.dir,
424 &["src/**/*.ts".to_owned()],
425 &["vendor/**".to_owned()],
426 )
427 .expect("builds");
428
429 assert_eq!(
430 discovery.rejects(&FilePath::new("src/a.ts")),
431 None,
432 "a file the globs select is not rejected"
433 );
434 assert_eq!(
435 discovery.rejects(&FilePath::new("vendor/x.ts")),
436 Some(Rejection::Excluded {
437 pattern: "vendor/**".to_owned()
438 }),
439 );
440 assert_eq!(
441 discovery.rejects(&FilePath::new("other/a.ts")),
442 Some(Rejection::NotIncluded),
443 );
444 assert_eq!(
445 discovery.rejects(&FilePath::new(".lanekeep/driver.mjs")),
446 Some(Rejection::Lanekeep),
447 );
448 }
449}