Skip to main content

lanekeep_core/
discovery.rs

1//! Finding the files to check.
2//!
3//! Discovery is gitignore-aware, so build output and vendored dependencies are skipped
4//! without every project having to exclude them by hand.
5//!
6//! The returned order is sorted. Nothing downstream depends on it — violations are sorted
7//! before reporting — but discovery feeding files to workers in filesystem order would make
8//! the *work distribution* vary between runs on identical input, which turns a timing
9//! difference into something that looks like nondeterminism when a run breaches a budget.
10
11use std::path::{Path, PathBuf};
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use thiserror::Error;
15
16use crate::location::FilePath;
17
18/// Why discovery could not run.
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum DiscoveryError {
21    /// A glob in `include` or `exclude` is malformed.
22    #[error("invalid {field} pattern `{pattern}`: {detail}")]
23    InvalidGlob {
24        /// Which config field it came from.
25        field: &'static str,
26        /// The pattern as written.
27        pattern: String,
28        /// What is wrong with it.
29        detail: String,
30    },
31
32    /// The project root cannot be walked.
33    #[error("cannot read project root `{path}`: {detail}")]
34    Unreadable {
35        /// The root as given.
36        path: String,
37        /// What went wrong.
38        detail: String,
39    },
40}
41
42/// Which files a run considers.
43#[derive(Debug)]
44pub struct Discovery {
45    root: PathBuf,
46    include: GlobSet,
47    exclude: GlobSet,
48    has_include: bool,
49}
50
51impl Discovery {
52    /// Build a discovery over a project root.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`DiscoveryError::InvalidGlob`] for a malformed pattern, with the field it
57    /// came from — an error naming only the pattern leaves the reader searching for it.
58    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    /// The project root, canonicalized.
80    #[must_use]
81    pub fn root(&self) -> &Path {
82        &self.root
83    }
84
85    /// Whether a path relative to the root is selected.
86    ///
87    /// Exclusion wins over inclusion: a project listing a broad `include` and a narrow
88    /// `exclude` means the exclusion, and the other order would make `exclude` useless.
89    #[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        // No `include` at all means everything the walk turned up, which is the useful
96        // default for `lanekeep check` in a small project.
97        !self.has_include || self.include.is_match(path)
98    }
99
100    /// Every selected file, sorted.
101    ///
102    /// Infallible: the root was canonicalized when this was built, and a single unreadable
103    /// entry is skipped rather than failing a run over a tree that may contain anything.
104    #[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            // Honor .gitignore even outside a repository. The walker otherwise treats
115            // ignore files as meaningless without a .git directory, which would make
116            // discovery depend on whether the project happens to be checked out — the
117            // same tree giving different answers in a tarball than in a clone.
118            .require_git(false)
119            .build()
120        {
121            // A single unreadable entry is not a reason to fail the run.
122            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        // The other order would make `exclude` useless, since anything excluded is by
230        // definition something `include` matched.
231        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        // Nothing downstream depends on this order, but feeding workers in filesystem
254        // order would make work distribution vary run to run — which looks like
255        // nondeterminism the moment a run breaches a budget.
256        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}