Skip to main content

cuke_dedup/
discovery.rs

1//! Gitignore-aware repository discovery.
2
3use crate::config::Config;
4use crate::gherkin::FeatureFormat;
5use crate::source_adapter;
6pub use crate::source_adapter::{SourceFile, SourceLanguage};
7use anyhow::{Context, Result};
8use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
9use ignore::WalkBuilder;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13/// A discovered feature file together with its parser and matching config pattern.
14#[non_exhaustive]
15pub struct FeatureFile {
16    /// Absolute feature-file path.
17    pub path: PathBuf,
18    /// Parser selected from the discovered filename.
19    pub format: FeatureFormat,
20    /// Effective glob pattern that selected this file.
21    pub pattern: String,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25/// Deterministically ordered files discovered for one analysis run.
26#[non_exhaustive]
27pub struct DiscoveredFiles {
28    /// Resolved source-loading edges used by definition extraction.
29    pub dependencies: Vec<source_adapter::SourceDependency>,
30    /// Source-loading calls that dependency discovery refused to follow, with the reason.
31    pub dependency_gaps: Vec<source_adapter::DependencyGap>,
32    /// Gherkin feature files and their selected parser formats.
33    pub features: Vec<FeatureFile>,
34    /// Definition sources selected through their registered frontend.
35    pub definitions: Vec<SourceFile>,
36    /// Sources of opt-in languages that were not selected because automatic discovery or only
37    /// suffix-agnostic definition patterns matched them, less those that dependency resolution
38    /// then followed a load to and analyzed.
39    pub skipped_definitions: Vec<SourceFile>,
40    /// Non-fatal filesystem traversal errors collected during discovery.
41    pub errors: Vec<String>,
42    /// Configured feature patterns that matched no files.
43    pub unmatched_feature_patterns: Vec<String>,
44    /// Configured definition patterns that matched no files.
45    pub unmatched_definition_patterns: Vec<String>,
46}
47
48/// Discovers configured feature and definition files while respecting ignore rules.
49pub fn discover(config: &Config) -> Result<DiscoveredFiles> {
50    let feature_globs = compile_pattern_matchers(&config.features, "feature")?;
51    let definition_globs = if config.definitions.is_empty() {
52        None
53    } else {
54        Some(compile_pattern_matchers(&config.definitions, "definition")?)
55    };
56    let excludes = compile_globs(&config.exclude, "exclude")?;
57    let mut files = DiscoveredFiles::default();
58
59    let root = config.root.clone();
60    let walk_excludes = excludes.clone();
61    let mut builder = WalkBuilder::new(&config.root);
62    builder
63        .standard_filters(true)
64        .add_custom_ignore_filename(".cuke-dedupignore")
65        .require_git(false)
66        .hidden(!config.include_hidden)
67        .follow_links(false)
68        .filter_entry(move |entry| {
69            if entry.depth() == 0 {
70                return true;
71            }
72            let relative = entry.path().strip_prefix(&root).unwrap_or(entry.path());
73            let directory_form = format!("{}/", relative.to_string_lossy().replace('\\', "/"));
74            !walk_excludes.is_match(relative) && !walk_excludes.is_match(directory_form)
75        });
76    let walker = builder.build();
77
78    for entry in walker {
79        let entry = match entry {
80            Ok(entry) => entry,
81            Err(error) => {
82                files.errors.push(format!(
83                    "failed while walking target directory {}: {error}",
84                    config.root.display()
85                ));
86                continue;
87            }
88        };
89        match entry.file_type() {
90            Some(file_type) if file_type.is_file() => {}
91            _ => continue,
92        }
93        let path = entry.into_path();
94        let relative = path.strip_prefix(&config.root).unwrap_or(&path);
95        if excludes.is_match(relative) {
96            continue;
97        }
98
99        if let Some((_, pattern)) = feature_globs
100            .iter()
101            .find(|(matcher, _)| matcher.is_match(relative))
102        {
103            files.features.push(FeatureFile {
104                format: FeatureFormat::from_path(relative),
105                path,
106                pattern: pattern.clone(),
107            });
108            continue;
109        }
110
111        if let Some(language) = source_language(relative) {
112            let opt_in = !source_adapter::adapter_for_language(language).discover_by_default();
113            let selection = match &definition_globs {
114                None => Some(!opt_in),
115                Some(globs) => {
116                    let mut matching = globs
117                        .iter()
118                        .filter(|(matcher, _)| matcher.is_match(relative))
119                        .peekable();
120                    matching.peek().is_some().then(|| {
121                        !opt_in || matching.any(|(matcher, _)| names_suffix(matcher, relative))
122                    })
123                }
124            };
125            match selection {
126                Some(true) => files.definitions.push(SourceFile { path, language }),
127                Some(false) => files
128                    .skipped_definitions
129                    .push(SourceFile { path, language }),
130                None => {}
131            }
132        }
133    }
134
135    files
136        .features
137        .sort_by(|left, right| left.path.cmp(&right.path));
138    files.unmatched_feature_patterns = feature_globs
139        .iter()
140        .filter(|(matcher, _)| {
141            !files.features.iter().any(|file| {
142                matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
143            })
144        })
145        .map(|(_, pattern)| pattern.clone())
146        .collect();
147    files
148        .definitions
149        .sort_by(|left, right| left.path.cmp(&right.path));
150    files.unmatched_definition_patterns = definition_globs
151        .as_ref()
152        .into_iter()
153        .flatten()
154        .filter(|(matcher, _)| {
155            !files
156                .definitions
157                .iter()
158                .chain(&files.skipped_definitions)
159                .any(|file| {
160                    matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
161                })
162        })
163        .map(|(_, pattern)| pattern.clone())
164        .collect();
165    source_adapter::discover_dependencies(config, &excludes, &mut files);
166    Ok(files)
167}
168
169/// Compiles patterns into matchers paired with their original text for diagnostics.
170fn compile_pattern_matchers(patterns: &[String], kind: &str) -> Result<Vec<(GlobMatcher, String)>> {
171    patterns
172        .iter()
173        .map(|pattern| {
174            let glob = Glob::new(pattern)
175                .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?;
176            Ok((glob.compile_matcher(), pattern.clone()))
177        })
178        .collect()
179}
180
181/// Builds one `GlobSet` from `patterns`; an empty list matches nothing. Errors name the first
182/// invalid pattern and its `kind`.
183fn compile_globs(patterns: &[String], kind: &str) -> Result<GlobSet> {
184    let mut builder = GlobSetBuilder::new();
185    for pattern in patterns {
186        builder.add(
187            Glob::new(pattern)
188                .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?,
189        );
190    }
191    builder
192        .build()
193        .with_context(|| format!("failed to build {kind} glob set"))
194}
195
196/// Opt-in languages are selected only by a pattern that depends on their suffix, so a broad
197/// pattern such as `features/**` keeps selecting the same files it did before the language existed.
198fn names_suffix(matcher: &GlobMatcher, path: &Path) -> bool {
199    !matcher.is_match(path.with_extension("cuke-dedup-suffix-probe"))
200}
201
202/// Returns the registered frontend language for `path`'s extension; `None` for unregistered
203/// extensions, `.map` files and TypeScript declaration files.
204fn source_language(path: &Path) -> Option<SourceLanguage> {
205    source_adapter::language_for_path(path)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::config::ConfigOverrides;
212    use std::fs;
213
214    /// Default discovery finds one feature and one definition, skipping a source in a
215    /// `.gitignore`d directory.
216    #[test]
217    fn discovers_sources_and_features_but_uses_gitignore() {
218        let directory = tempfile::tempdir().unwrap();
219        fs::create_dir_all(directory.path().join("features/steps")).unwrap();
220        fs::create_dir_all(directory.path().join("ignored")).unwrap();
221        fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
222        fs::write(
223            directory.path().join("features/example.feature"),
224            "Feature: x",
225        )
226        .unwrap();
227        fs::write(
228            directory.path().join("features/steps/example.ts"),
229            "Given('x', () => {})",
230        )
231        .unwrap();
232        fs::write(
233            directory.path().join("ignored/step.ts"),
234            "Given('y', () => {})",
235        )
236        .unwrap();
237        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
238
239        let files = discover(&config).unwrap();
240        assert_eq!(files.features.len(), 1);
241        assert_eq!(files.definitions.len(), 1);
242    }
243
244    /// `.cuke-dedupignore` excludes by directory and glob, `!` re-includes, and a nested file's
245    /// rules apply only below its own directory.
246    #[test]
247    fn cuke_dedup_ignore_supports_scoped_gitignore_rules_and_negation() {
248        let directory = tempfile::tempdir().unwrap();
249        fs::create_dir_all(directory.path().join("ignored")).unwrap();
250        fs::create_dir_all(directory.path().join("packages/first")).unwrap();
251        fs::create_dir_all(directory.path().join("packages/second")).unwrap();
252        fs::write(
253            directory.path().join(".cuke-dedupignore"),
254            "ignored/\n*.generated.ts\n!keep.generated.ts\n",
255        )
256        .unwrap();
257        fs::write(
258            directory.path().join("packages/first/.cuke-dedupignore"),
259            "local.ts\n",
260        )
261        .unwrap();
262        for path in [
263            "included.ts",
264            "ignored/step.ts",
265            "drop.generated.ts",
266            "keep.generated.ts",
267            "packages/first/local.ts",
268            "packages/second/local.ts",
269        ] {
270            fs::write(directory.path().join(path), "Given('x', () => work())").unwrap();
271        }
272
273        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
274        let root = config.root.clone();
275        let files = discover(&config).unwrap();
276        let relative = files
277            .definitions
278            .iter()
279            .map(|file| {
280                file.path
281                    .strip_prefix(&root)
282                    .unwrap()
283                    .to_string_lossy()
284                    .replace('\\', "/")
285            })
286            .collect::<Vec<_>>();
287
288        assert_eq!(
289            relative,
290            vec![
291                "included.ts",
292                "keep.generated.ts",
293                "packages/second/local.ts"
294            ]
295        );
296    }
297
298    /// `.feature` and `.feature.md` files are discovered with their respective formats, and no
299    /// feature pattern is reported unmatched.
300    #[test]
301    fn discovers_classic_and_markdown_features_with_parser_metadata() {
302        let directory = tempfile::tempdir().unwrap();
303        fs::create_dir_all(directory.path().join("features")).unwrap();
304        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
305        fs::write(
306            directory.path().join("features/b.feature.md"),
307            "# Feature: B",
308        )
309        .unwrap();
310        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
311
312        let files = discover(&config).unwrap();
313        assert_eq!(files.features.len(), 2);
314        assert_eq!(files.features[0].format, FeatureFormat::Gherkin);
315        assert_eq!(files.features[1].format, FeatureFormat::GherkinMarkdown);
316        assert!(files.unmatched_feature_patterns.is_empty());
317    }
318
319    /// A file matched by two feature patterns leaves neither reported as unmatched.
320    #[test]
321    fn overlapping_feature_patterns_are_both_counted_as_matched() {
322        let directory = tempfile::tempdir().unwrap();
323        fs::create_dir_all(directory.path().join("features")).unwrap();
324        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
325        let config = Config::load(
326            directory.path(),
327            ConfigOverrides {
328                features: Some(vec![
329                    "**/*.feature".to_owned(),
330                    "features/**/*.feature".to_owned(),
331                ]),
332                ..ConfigOverrides::default()
333            },
334        )
335        .unwrap();
336
337        let files = discover(&config).unwrap();
338        assert!(files.unmatched_feature_patterns.is_empty());
339    }
340
341    /// A configured definition glob selects only the source it matches and is not reported
342    /// unmatched.
343    #[test]
344    fn configured_definition_globs_narrow_source_files() {
345        let directory = tempfile::tempdir().unwrap();
346        fs::create_dir_all(directory.path().join("steps")).unwrap();
347        fs::write(directory.path().join("steps/a.ts"), "").unwrap();
348        fs::write(directory.path().join("other.ts"), "").unwrap();
349        let overrides = ConfigOverrides {
350            definitions: Some(vec!["steps/**/*.ts".to_owned()]),
351            ..ConfigOverrides::default()
352        };
353        let config = Config::load(directory.path(), overrides).unwrap();
354
355        let files = discover(&config).unwrap();
356        assert_eq!(files.definitions.len(), 1);
357        assert!(files.definitions[0].path.ends_with("steps/a.ts"));
358        assert!(files.unmatched_definition_patterns.is_empty());
359    }
360
361    /// Compiled globs let `*` cross path separators and expand `{a,b}` braces, and still reject a
362    /// path whose name matches no alternative.
363    #[test]
364    fn configured_globs_use_documented_cross_separator_and_brace_semantics() {
365        let globs = compile_globs(
366            &[
367                "*.ts".to_owned(),
368                "features/*.{feature,feature.md}".to_owned(),
369            ],
370            "test",
371        )
372        .unwrap();
373        assert!(globs.is_match(Path::new("nested/deep/steps.ts")));
374        assert!(globs.is_match(Path::new("features/nested/example.feature.md")));
375        assert!(!globs.is_match(Path::new("features/example.md")));
376    }
377
378    /// A definition glob that matches no file selects no definitions and is reported unmatched by
379    /// its original text.
380    #[test]
381    fn reports_each_unmatched_definition_glob() {
382        let directory = tempfile::tempdir().unwrap();
383        fs::write(
384            directory.path().join("steps.ts"),
385            "Given('x', () => work())",
386        )
387        .unwrap();
388        let config = Config::load(
389            directory.path(),
390            ConfigOverrides {
391                definitions: Some(vec!["stpes/**/*.ts".to_owned()]),
392                ..ConfigOverrides::default()
393            },
394        )
395        .unwrap();
396
397        let files = discover(&config).unwrap();
398        assert!(files.definitions.is_empty());
399        assert_eq!(files.unmatched_definition_patterns, ["stpes/**/*.ts"]);
400    }
401
402    /// `.d.ts`, `.d.mts`, `.d.cts` and `.js.map` paths map to no source language.
403    #[test]
404    fn declaration_and_source_map_files_are_not_definition_sources() {
405        for path in ["types.d.ts", "types.d.mts", "types.d.cts", "code.js.map"] {
406            assert_eq!(source_language(Path::new(path)), None, "{path}");
407        }
408    }
409
410    /// A source under `node_modules` is excluded by default and selected once default excludes are
411    /// disabled and a definition glob names it.
412    #[test]
413    fn built_in_excludes_prune_sources_unless_explicitly_disabled() {
414        let directory = tempfile::tempdir().unwrap();
415        fs::create_dir_all(directory.path().join("node_modules/workspace")).unwrap();
416        fs::write(
417            directory.path().join("node_modules/workspace/steps.ts"),
418            "Given('nested', () => work())",
419        )
420        .unwrap();
421
422        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
423        assert!(discover(&default_config).unwrap().definitions.is_empty());
424
425        let enabled_config = Config::load(
426            directory.path(),
427            ConfigOverrides {
428                definitions: Some(vec!["node_modules/workspace/**/*.ts".to_owned()]),
429                exclude_defaults: Some(false),
430                ..ConfigOverrides::default()
431            },
432        )
433        .unwrap();
434        let files = discover(&enabled_config).unwrap();
435        assert_eq!(files.definitions.len(), 1);
436    }
437
438    /// A source in a hidden directory is skipped by default and found with `include_hidden` and a
439    /// matching definition glob.
440    #[test]
441    fn hidden_sources_require_the_include_hidden_option() {
442        let directory = tempfile::tempdir().unwrap();
443        fs::create_dir_all(directory.path().join(".steps")).unwrap();
444        fs::write(
445            directory.path().join(".steps/example.ts"),
446            "Given('hidden', () => work())",
447        )
448        .unwrap();
449        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
450        assert!(discover(&default_config).unwrap().definitions.is_empty());
451
452        let visible_config = Config::load(
453            directory.path(),
454            ConfigOverrides {
455                definitions: Some(vec![".steps/**/*.ts".to_owned()]),
456                include_hidden: Some(true),
457                ..ConfigOverrides::default()
458            },
459        )
460        .unwrap();
461        assert_eq!(discover(&visible_config).unwrap().definitions.len(), 1);
462    }
463
464    /// An unreadable directory records a traversal error while the readable source is still
465    /// discovered. Unix only.
466    #[cfg(unix)]
467    #[test]
468    fn traversal_errors_are_collected_without_discarding_other_files() {
469        use std::os::unix::fs::PermissionsExt;
470
471        let directory = tempfile::tempdir().unwrap();
472        fs::write(
473            directory.path().join("visible.ts"),
474            "Given('visible', () => work())",
475        )
476        .unwrap();
477        let locked = directory.path().join("locked");
478        fs::create_dir(&locked).unwrap();
479        fs::write(locked.join("hidden.ts"), "Given('hidden', () => work())").unwrap();
480        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
481
482        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
483        let files = discover(&config).unwrap();
484
485        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
486        assert_eq!(files.definitions.len(), 1);
487        assert!(!files.errors.is_empty());
488    }
489
490    /// An unreadable directory under excluded `node_modules` is never entered, so discovery records
491    /// no traversal error. Unix only.
492    #[cfg(unix)]
493    #[test]
494    fn excluded_directories_are_pruned_before_traversal() {
495        use std::os::unix::fs::PermissionsExt;
496
497        let directory = tempfile::tempdir().unwrap();
498        let locked = directory.path().join("node_modules/locked");
499        fs::create_dir_all(&locked).unwrap();
500        fs::write(locked.join("step.ts"), "Given('hidden', () => work())").unwrap();
501        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
502
503        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
504        let files = discover(&config).unwrap();
505
506        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
507        assert!(files.definitions.is_empty());
508        assert!(files.errors.is_empty());
509    }
510}