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)]
44pub struct Discovery {
45 root: PathBuf,
46 include: GlobSet,
47 exclude: GlobSet,
48 has_include: bool,
49}
50
51impl Discovery {
52 pub fn new(
59 root: impl AsRef<Path>,
60 include: &[String],
61 exclude: &[String],
62 ) -> Result<Self, DiscoveryError> {
63 let root = root.as_ref();
64 let canonical = root
65 .canonicalize()
66 .map_err(|e| DiscoveryError::Unreadable {
67 path: root.display().to_string(),
68 detail: e.to_string(),
69 })?;
70
71 Ok(Self {
72 root: canonical,
73 include: build_set(include, "include")?,
74 exclude: build_set(exclude, "exclude")?,
75 has_include: !include.is_empty(),
76 })
77 }
78
79 #[must_use]
81 pub fn root(&self) -> &Path {
82 &self.root
83 }
84
85 #[must_use]
94 pub fn selects(&self, relative: &FilePath) -> bool {
95 let path = relative.as_str();
96 if in_lanekeep_directory(path) {
97 return false;
98 }
99 if self.exclude.is_match(path) {
100 return false;
101 }
102 !self.has_include || self.include.is_match(path)
105 }
106
107 #[must_use]
112 pub fn walk(&self) -> Vec<FilePath> {
113 let mut out = Vec::new();
114
115 for entry in ignore::WalkBuilder::new(&self.root)
116 .hidden(false)
117 .git_ignore(true)
118 .git_global(true)
119 .git_exclude(true)
120 .parents(true)
121 .require_git(false)
126 .build()
127 {
128 let Ok(entry) = entry else { continue };
130 if !entry.file_type().is_some_and(|t| t.is_file()) {
131 continue;
132 }
133 let Ok(relative) = entry.path().strip_prefix(&self.root) else {
134 continue;
135 };
136
137 let relative = FilePath::new(relative);
138 if self.selects(&relative) {
139 out.push(relative);
140 }
141 }
142
143 out.sort();
144 out.dedup();
145 out
146 }
147}
148
149fn in_lanekeep_directory(relative: &str) -> bool {
163 relative
164 .split('/')
165 .next()
166 .is_some_and(|first| first == ".lanekeep")
167}
168
169fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
170 let mut builder = GlobSetBuilder::new();
171 for pattern in patterns {
172 let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
173 field,
174 pattern: pattern.clone(),
175 detail: e.to_string(),
176 })?;
177 builder.add(glob);
178 }
179 builder.build().map_err(|e| DiscoveryError::InvalidGlob {
180 field,
181 pattern: patterns.join(", "),
182 detail: e.to_string(),
183 })
184}
185
186#[cfg(test)]
187mod tests {
188 use std::fs;
189
190 use super::*;
191
192 struct Fixture {
193 dir: PathBuf,
194 }
195
196 impl Fixture {
197 fn new(name: &str, files: &[&str]) -> Self {
198 let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
199 let _ = fs::remove_dir_all(&dir);
200 for path in files {
201 let full = dir.join(path);
202 if let Some(parent) = full.parent() {
203 fs::create_dir_all(parent).expect("creates parent");
204 }
205 fs::write(&full, "const x = 1;\n").expect("writes");
206 }
207 fs::create_dir_all(&dir).expect("creates dir");
208 Self { dir }
209 }
210
211 fn write(&self, path: &str, contents: &str) {
212 let full = self.dir.join(path);
213 if let Some(parent) = full.parent() {
214 fs::create_dir_all(parent).expect("creates parent");
215 }
216 fs::write(full, contents).expect("writes");
217 }
218
219 fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
220 let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
221 let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
222 Discovery::new(&self.dir, &include, &exclude)
223 .expect("builds")
224 .walk()
225 .iter()
226 .map(|p| p.as_str().to_owned())
227 .collect()
228 }
229 }
230
231 impl Drop for Fixture {
232 fn drop(&mut self) {
233 let _ = fs::remove_dir_all(&self.dir);
234 }
235 }
236
237 #[test]
243 fn lanekeeps_own_directory_is_never_selected() {
244 let fixture = Fixture::new(
245 "own-directory",
246 &[
247 "src/a.ts",
248 ".lanekeep/driver-abc.mjs",
249 ".lanekeep/components/x.wasm",
250 "src/.lanekeep-notes.ts",
253 "vendor/.lanekeep/keep.ts",
254 ],
255 );
256 let found = fixture.walk(&["**/*"], &[]);
257 assert!(
258 !found.iter().any(|p| p.starts_with(".lanekeep/")),
259 "lanekeep's own directory reached the corpus: {found:?}"
260 );
261 assert!(found.contains(&"src/a.ts".to_owned()), "{found:?}");
262 assert!(
263 found.contains(&"src/.lanekeep-notes.ts".to_owned()),
264 "a project file whose name merely begins the same way is a subject: {found:?}"
265 );
266 assert!(
267 found.contains(&"vendor/.lanekeep/keep.ts".to_owned()),
268 "only the directory at the root is lanekeep's: {found:?}"
269 );
270 }
271
272 #[test]
274 fn selects_refuses_lanekeeps_own_directory() {
275 let fixture = Fixture::new("own-directory-selects", &["src/a.ts"]);
276 let discovery = Discovery::new(&fixture.dir, &[], &[]).expect("builds");
277 assert!(!discovery.selects(&FilePath::new(".lanekeep/driver-abc.mjs")));
278 assert!(discovery.selects(&FilePath::new("src/a.ts")));
279 }
280
281 #[test]
282 fn finds_files_matching_include() {
283 let fixture = Fixture::new(
284 "include",
285 &["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
286 );
287 assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
288 }
289
290 #[test]
291 fn no_include_selects_everything_found() {
292 let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
293 let found = fixture.walk(&[], &[]);
294 assert!(found.contains(&"a.ts".to_owned()));
295 assert!(found.contains(&"b.md".to_owned()));
296 }
297
298 #[test]
299 fn exclude_wins_over_include() {
300 let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
303 assert_eq!(
304 fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
305 ["src/a.ts"]
306 );
307 }
308
309 #[test]
310 fn respects_gitignore() {
311 let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
312 fixture.write(".gitignore", "dist/\n");
313
314 let found = fixture.walk(&["**/*.ts"], &[]);
315 assert!(found.contains(&"src/a.ts".to_owned()));
316 assert!(
317 !found.contains(&"dist/b.ts".to_owned()),
318 "gitignored files must not be checked: {found:?}"
319 );
320 }
321
322 #[test]
323 fn the_order_is_sorted_and_stable() {
324 let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
328 let first = fixture.walk(&["**/*.ts"], &[]);
329 assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
330
331 for _ in 0..5 {
332 assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
333 }
334 }
335
336 #[test]
337 fn reports_a_bad_glob_with_the_field_it_came_from() {
338 let fixture = Fixture::new("bad-glob", &["a.ts"]);
339 let err =
340 Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
341
342 match err {
343 DiscoveryError::InvalidGlob { field, pattern, .. } => {
344 assert_eq!(field, "include");
345 assert_eq!(pattern, "src/[");
346 }
347 DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
348 }
349
350 let err =
351 Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
352 assert!(
353 matches!(
354 err,
355 DiscoveryError::InvalidGlob {
356 field: "exclude",
357 ..
358 }
359 ),
360 "{err:?}"
361 );
362 }
363
364 #[test]
365 fn a_missing_root_is_reported() {
366 let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
367 assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
368 }
369
370 #[test]
371 fn selects_can_be_asked_without_walking() {
372 let fixture = Fixture::new("selects", &["a.ts"]);
373 let discovery = Discovery::new(
374 &fixture.dir,
375 &["src/**/*.ts".to_owned()],
376 &["**/*.test.ts".to_owned()],
377 )
378 .expect("builds");
379
380 assert!(discovery.selects(&FilePath::new("src/a.ts")));
381 assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
382 assert!(!discovery.selects(&FilePath::new("other/a.ts")));
383 }
384}