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
181fn compile_globs(patterns: &[String], kind: &str) -> Result<GlobSet> {
182    let mut builder = GlobSetBuilder::new();
183    for pattern in patterns {
184        builder.add(
185            Glob::new(pattern)
186                .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?,
187        );
188    }
189    builder
190        .build()
191        .with_context(|| format!("failed to build {kind} glob set"))
192}
193
194/// Opt-in languages are selected only by a pattern that depends on their suffix, so a broad
195/// pattern such as `features/**` keeps selecting the same files it did before the language existed.
196fn names_suffix(matcher: &GlobMatcher, path: &Path) -> bool {
197    !matcher.is_match(path.with_extension("cuke-dedup-suffix-probe"))
198}
199
200fn source_language(path: &Path) -> Option<SourceLanguage> {
201    source_adapter::language_for_path(path)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::config::ConfigOverrides;
208    use std::fs;
209
210    #[test]
211    fn discovers_sources_and_features_but_uses_gitignore() {
212        let directory = tempfile::tempdir().unwrap();
213        fs::create_dir_all(directory.path().join("features/steps")).unwrap();
214        fs::create_dir_all(directory.path().join("ignored")).unwrap();
215        fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
216        fs::write(
217            directory.path().join("features/example.feature"),
218            "Feature: x",
219        )
220        .unwrap();
221        fs::write(
222            directory.path().join("features/steps/example.ts"),
223            "Given('x', () => {})",
224        )
225        .unwrap();
226        fs::write(
227            directory.path().join("ignored/step.ts"),
228            "Given('y', () => {})",
229        )
230        .unwrap();
231        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
232
233        let files = discover(&config).unwrap();
234        assert_eq!(files.features.len(), 1);
235        assert_eq!(files.definitions.len(), 1);
236    }
237
238    #[test]
239    fn cuke_dedup_ignore_supports_scoped_gitignore_rules_and_negation() {
240        let directory = tempfile::tempdir().unwrap();
241        fs::create_dir_all(directory.path().join("ignored")).unwrap();
242        fs::create_dir_all(directory.path().join("packages/first")).unwrap();
243        fs::create_dir_all(directory.path().join("packages/second")).unwrap();
244        fs::write(
245            directory.path().join(".cuke-dedupignore"),
246            "ignored/\n*.generated.ts\n!keep.generated.ts\n",
247        )
248        .unwrap();
249        fs::write(
250            directory.path().join("packages/first/.cuke-dedupignore"),
251            "local.ts\n",
252        )
253        .unwrap();
254        for path in [
255            "included.ts",
256            "ignored/step.ts",
257            "drop.generated.ts",
258            "keep.generated.ts",
259            "packages/first/local.ts",
260            "packages/second/local.ts",
261        ] {
262            fs::write(directory.path().join(path), "Given('x', () => work())").unwrap();
263        }
264
265        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
266        let root = config.root.clone();
267        let files = discover(&config).unwrap();
268        let relative = files
269            .definitions
270            .iter()
271            .map(|file| {
272                file.path
273                    .strip_prefix(&root)
274                    .unwrap()
275                    .to_string_lossy()
276                    .replace('\\', "/")
277            })
278            .collect::<Vec<_>>();
279
280        assert_eq!(
281            relative,
282            vec![
283                "included.ts",
284                "keep.generated.ts",
285                "packages/second/local.ts"
286            ]
287        );
288    }
289
290    #[test]
291    fn discovers_classic_and_markdown_features_with_parser_metadata() {
292        let directory = tempfile::tempdir().unwrap();
293        fs::create_dir_all(directory.path().join("features")).unwrap();
294        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
295        fs::write(
296            directory.path().join("features/b.feature.md"),
297            "# Feature: B",
298        )
299        .unwrap();
300        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
301
302        let files = discover(&config).unwrap();
303        assert_eq!(files.features.len(), 2);
304        assert_eq!(files.features[0].format, FeatureFormat::Gherkin);
305        assert_eq!(files.features[1].format, FeatureFormat::GherkinMarkdown);
306        assert!(files.unmatched_feature_patterns.is_empty());
307    }
308
309    #[test]
310    fn overlapping_feature_patterns_are_both_counted_as_matched() {
311        let directory = tempfile::tempdir().unwrap();
312        fs::create_dir_all(directory.path().join("features")).unwrap();
313        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
314        let config = Config::load(
315            directory.path(),
316            ConfigOverrides {
317                features: Some(vec![
318                    "**/*.feature".to_owned(),
319                    "features/**/*.feature".to_owned(),
320                ]),
321                ..ConfigOverrides::default()
322            },
323        )
324        .unwrap();
325
326        let files = discover(&config).unwrap();
327        assert!(files.unmatched_feature_patterns.is_empty());
328    }
329
330    #[test]
331    fn configured_definition_globs_narrow_source_files() {
332        let directory = tempfile::tempdir().unwrap();
333        fs::create_dir_all(directory.path().join("steps")).unwrap();
334        fs::write(directory.path().join("steps/a.ts"), "").unwrap();
335        fs::write(directory.path().join("other.ts"), "").unwrap();
336        let overrides = ConfigOverrides {
337            definitions: Some(vec!["steps/**/*.ts".to_owned()]),
338            ..ConfigOverrides::default()
339        };
340        let config = Config::load(directory.path(), overrides).unwrap();
341
342        let files = discover(&config).unwrap();
343        assert_eq!(files.definitions.len(), 1);
344        assert!(files.definitions[0].path.ends_with("steps/a.ts"));
345        assert!(files.unmatched_definition_patterns.is_empty());
346    }
347
348    #[test]
349    fn configured_globs_use_documented_cross_separator_and_brace_semantics() {
350        let globs = compile_globs(
351            &[
352                "*.ts".to_owned(),
353                "features/*.{feature,feature.md}".to_owned(),
354            ],
355            "test",
356        )
357        .unwrap();
358        assert!(globs.is_match(Path::new("nested/deep/steps.ts")));
359        assert!(globs.is_match(Path::new("features/nested/example.feature.md")));
360        assert!(!globs.is_match(Path::new("features/example.md")));
361    }
362
363    #[test]
364    fn reports_each_unmatched_definition_glob() {
365        let directory = tempfile::tempdir().unwrap();
366        fs::write(
367            directory.path().join("steps.ts"),
368            "Given('x', () => work())",
369        )
370        .unwrap();
371        let config = Config::load(
372            directory.path(),
373            ConfigOverrides {
374                definitions: Some(vec!["stpes/**/*.ts".to_owned()]),
375                ..ConfigOverrides::default()
376            },
377        )
378        .unwrap();
379
380        let files = discover(&config).unwrap();
381        assert!(files.definitions.is_empty());
382        assert_eq!(files.unmatched_definition_patterns, ["stpes/**/*.ts"]);
383    }
384
385    #[test]
386    fn declaration_and_source_map_files_are_not_definition_sources() {
387        for path in ["types.d.ts", "types.d.mts", "types.d.cts", "code.js.map"] {
388            assert_eq!(source_language(Path::new(path)), None, "{path}");
389        }
390    }
391
392    #[test]
393    fn built_in_excludes_prune_sources_unless_explicitly_disabled() {
394        let directory = tempfile::tempdir().unwrap();
395        fs::create_dir_all(directory.path().join("node_modules/workspace")).unwrap();
396        fs::write(
397            directory.path().join("node_modules/workspace/steps.ts"),
398            "Given('nested', () => work())",
399        )
400        .unwrap();
401
402        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
403        assert!(discover(&default_config).unwrap().definitions.is_empty());
404
405        let enabled_config = Config::load(
406            directory.path(),
407            ConfigOverrides {
408                definitions: Some(vec!["node_modules/workspace/**/*.ts".to_owned()]),
409                exclude_defaults: Some(false),
410                ..ConfigOverrides::default()
411            },
412        )
413        .unwrap();
414        let files = discover(&enabled_config).unwrap();
415        assert_eq!(files.definitions.len(), 1);
416    }
417
418    #[test]
419    fn hidden_sources_require_the_include_hidden_option() {
420        let directory = tempfile::tempdir().unwrap();
421        fs::create_dir_all(directory.path().join(".steps")).unwrap();
422        fs::write(
423            directory.path().join(".steps/example.ts"),
424            "Given('hidden', () => work())",
425        )
426        .unwrap();
427        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
428        assert!(discover(&default_config).unwrap().definitions.is_empty());
429
430        let visible_config = Config::load(
431            directory.path(),
432            ConfigOverrides {
433                definitions: Some(vec![".steps/**/*.ts".to_owned()]),
434                include_hidden: Some(true),
435                ..ConfigOverrides::default()
436            },
437        )
438        .unwrap();
439        assert_eq!(discover(&visible_config).unwrap().definitions.len(), 1);
440    }
441
442    #[cfg(unix)]
443    #[test]
444    fn traversal_errors_are_collected_without_discarding_other_files() {
445        use std::os::unix::fs::PermissionsExt;
446
447        let directory = tempfile::tempdir().unwrap();
448        fs::write(
449            directory.path().join("visible.ts"),
450            "Given('visible', () => work())",
451        )
452        .unwrap();
453        let locked = directory.path().join("locked");
454        fs::create_dir(&locked).unwrap();
455        fs::write(locked.join("hidden.ts"), "Given('hidden', () => work())").unwrap();
456        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
457
458        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
459        let files = discover(&config).unwrap();
460
461        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
462        assert_eq!(files.definitions.len(), 1);
463        assert!(!files.errors.is_empty());
464    }
465
466    #[cfg(unix)]
467    #[test]
468    fn excluded_directories_are_pruned_before_traversal() {
469        use std::os::unix::fs::PermissionsExt;
470
471        let directory = tempfile::tempdir().unwrap();
472        let locked = directory.path().join("node_modules/locked");
473        fs::create_dir_all(&locked).unwrap();
474        fs::write(locked.join("step.ts"), "Given('hidden', () => work())").unwrap();
475        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
476
477        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
478        let files = discover(&config).unwrap();
479
480        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
481        assert!(files.definitions.is_empty());
482        assert!(files.errors.is_empty());
483    }
484}