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]
90 pub fn selects(&self, relative: &FilePath) -> bool {
91 let path = relative.as_str();
92 if self.exclude.is_match(path) {
93 return false;
94 }
95 !self.has_include || self.include.is_match(path)
98 }
99
100 #[must_use]
105 pub fn walk(&self) -> Vec<FilePath> {
106 let mut out = Vec::new();
107
108 for entry in ignore::WalkBuilder::new(&self.root)
109 .hidden(false)
110 .git_ignore(true)
111 .git_global(true)
112 .git_exclude(true)
113 .parents(true)
114 .require_git(false)
119 .build()
120 {
121 let Ok(entry) = entry else { continue };
123 if !entry.file_type().is_some_and(|t| t.is_file()) {
124 continue;
125 }
126 let Ok(relative) = entry.path().strip_prefix(&self.root) else {
127 continue;
128 };
129
130 let relative = FilePath::new(relative);
131 if self.selects(&relative) {
132 out.push(relative);
133 }
134 }
135
136 out.sort();
137 out.dedup();
138 out
139 }
140}
141
142fn build_set(patterns: &[String], field: &'static str) -> Result<GlobSet, DiscoveryError> {
143 let mut builder = GlobSetBuilder::new();
144 for pattern in patterns {
145 let glob = Glob::new(pattern).map_err(|e| DiscoveryError::InvalidGlob {
146 field,
147 pattern: pattern.clone(),
148 detail: e.to_string(),
149 })?;
150 builder.add(glob);
151 }
152 builder.build().map_err(|e| DiscoveryError::InvalidGlob {
153 field,
154 pattern: patterns.join(", "),
155 detail: e.to_string(),
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use std::fs;
162
163 use super::*;
164
165 struct Fixture {
166 dir: PathBuf,
167 }
168
169 impl Fixture {
170 fn new(name: &str, files: &[&str]) -> Self {
171 let dir = std::env::temp_dir().join(format!("lanekeep-discovery-{name}"));
172 let _ = fs::remove_dir_all(&dir);
173 for path in files {
174 let full = dir.join(path);
175 if let Some(parent) = full.parent() {
176 fs::create_dir_all(parent).expect("creates parent");
177 }
178 fs::write(&full, "const x = 1;\n").expect("writes");
179 }
180 fs::create_dir_all(&dir).expect("creates dir");
181 Self { dir }
182 }
183
184 fn write(&self, path: &str, contents: &str) {
185 let full = self.dir.join(path);
186 if let Some(parent) = full.parent() {
187 fs::create_dir_all(parent).expect("creates parent");
188 }
189 fs::write(full, contents).expect("writes");
190 }
191
192 fn walk(&self, include: &[&str], exclude: &[&str]) -> Vec<String> {
193 let include: Vec<String> = include.iter().map(|s| (*s).to_owned()).collect();
194 let exclude: Vec<String> = exclude.iter().map(|s| (*s).to_owned()).collect();
195 Discovery::new(&self.dir, &include, &exclude)
196 .expect("builds")
197 .walk()
198 .iter()
199 .map(|p| p.as_str().to_owned())
200 .collect()
201 }
202 }
203
204 impl Drop for Fixture {
205 fn drop(&mut self) {
206 let _ = fs::remove_dir_all(&self.dir);
207 }
208 }
209
210 #[test]
211 fn finds_files_matching_include() {
212 let fixture = Fixture::new(
213 "include",
214 &["src/a.ts", "src/b.tsx", "src/c.md", "other/d.ts"],
215 );
216 assert_eq!(fixture.walk(&["src/**/*.ts"], &[]), ["src/a.ts"]);
217 }
218
219 #[test]
220 fn no_include_selects_everything_found() {
221 let fixture = Fixture::new("no-include", &["a.ts", "b.md"]);
222 let found = fixture.walk(&[], &[]);
223 assert!(found.contains(&"a.ts".to_owned()));
224 assert!(found.contains(&"b.md".to_owned()));
225 }
226
227 #[test]
228 fn exclude_wins_over_include() {
229 let fixture = Fixture::new("exclude", &["src/a.ts", "src/a.test.ts"]);
232 assert_eq!(
233 fixture.walk(&["src/**/*.ts"], &["**/*.test.ts"]),
234 ["src/a.ts"]
235 );
236 }
237
238 #[test]
239 fn respects_gitignore() {
240 let fixture = Fixture::new("gitignore", &["src/a.ts", "dist/b.ts"]);
241 fixture.write(".gitignore", "dist/\n");
242
243 let found = fixture.walk(&["**/*.ts"], &[]);
244 assert!(found.contains(&"src/a.ts".to_owned()));
245 assert!(
246 !found.contains(&"dist/b.ts".to_owned()),
247 "gitignored files must not be checked: {found:?}"
248 );
249 }
250
251 #[test]
252 fn the_order_is_sorted_and_stable() {
253 let fixture = Fixture::new("order", &["z.ts", "a.ts", "m/n.ts", "b.ts"]);
257 let first = fixture.walk(&["**/*.ts"], &[]);
258 assert_eq!(first, ["a.ts", "b.ts", "m/n.ts", "z.ts"]);
259
260 for _ in 0..5 {
261 assert_eq!(fixture.walk(&["**/*.ts"], &[]), first);
262 }
263 }
264
265 #[test]
266 fn reports_a_bad_glob_with_the_field_it_came_from() {
267 let fixture = Fixture::new("bad-glob", &["a.ts"]);
268 let err =
269 Discovery::new(&fixture.dir, &["src/[".to_owned()], &[]).expect_err("malformed glob");
270
271 match err {
272 DiscoveryError::InvalidGlob { field, pattern, .. } => {
273 assert_eq!(field, "include");
274 assert_eq!(pattern, "src/[");
275 }
276 DiscoveryError::Unreadable { .. } => panic!("wrong error variant"),
277 }
278
279 let err =
280 Discovery::new(&fixture.dir, &[], &["**/[".to_owned()]).expect_err("malformed glob");
281 assert!(
282 matches!(
283 err,
284 DiscoveryError::InvalidGlob {
285 field: "exclude",
286 ..
287 }
288 ),
289 "{err:?}"
290 );
291 }
292
293 #[test]
294 fn a_missing_root_is_reported() {
295 let err = Discovery::new("/definitely/not/here", &[], &[]).expect_err("no such root");
296 assert!(matches!(err, DiscoveryError::Unreadable { .. }), "{err:?}");
297 }
298
299 #[test]
300 fn selects_can_be_asked_without_walking() {
301 let fixture = Fixture::new("selects", &["a.ts"]);
302 let discovery = Discovery::new(
303 &fixture.dir,
304 &["src/**/*.ts".to_owned()],
305 &["**/*.test.ts".to_owned()],
306 )
307 .expect("builds");
308
309 assert!(discovery.selects(&FilePath::new("src/a.ts")));
310 assert!(!discovery.selects(&FilePath::new("src/a.test.ts")));
311 assert!(!discovery.selects(&FilePath::new("other/a.ts")));
312 }
313}