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    /// Gherkin feature files and their selected parser formats.
29    pub features: Vec<FeatureFile>,
30    /// JavaScript or TypeScript definition sources.
31    pub definitions: Vec<SourceFile>,
32    /// Non-fatal filesystem traversal errors collected during discovery.
33    pub errors: Vec<String>,
34    /// Configured feature patterns that matched no files.
35    pub unmatched_feature_patterns: Vec<String>,
36    /// Configured definition patterns that matched no files.
37    pub unmatched_definition_patterns: Vec<String>,
38}
39
40/// Discovers configured feature and definition files while respecting ignore rules.
41pub fn discover(config: &Config) -> Result<DiscoveredFiles> {
42    let feature_globs = compile_pattern_matchers(&config.features, "feature")?;
43    let definition_globs = if config.definitions.is_empty() {
44        None
45    } else {
46        Some(compile_pattern_matchers(&config.definitions, "definition")?)
47    };
48    let excludes = compile_globs(&config.exclude, "exclude")?;
49    let mut files = DiscoveredFiles::default();
50
51    let root = config.root.clone();
52    let walk_excludes = excludes.clone();
53    let mut builder = WalkBuilder::new(&config.root);
54    builder
55        .standard_filters(true)
56        .add_custom_ignore_filename(".cuke-dedupignore")
57        .require_git(false)
58        .hidden(!config.include_hidden)
59        .follow_links(false)
60        .filter_entry(move |entry| {
61            if entry.depth() == 0 {
62                return true;
63            }
64            let relative = entry.path().strip_prefix(&root).unwrap_or(entry.path());
65            let directory_form = format!("{}/", relative.to_string_lossy().replace('\\', "/"));
66            !walk_excludes.is_match(relative) && !walk_excludes.is_match(directory_form)
67        });
68    let walker = builder.build();
69
70    for entry in walker {
71        let entry = match entry {
72            Ok(entry) => entry,
73            Err(error) => {
74                files.errors.push(format!(
75                    "failed while walking target directory {}: {error}",
76                    config.root.display()
77                ));
78                continue;
79            }
80        };
81        match entry.file_type() {
82            Some(file_type) if file_type.is_file() => {}
83            _ => continue,
84        }
85        let path = entry.into_path();
86        let relative = path.strip_prefix(&config.root).unwrap_or(&path);
87        if excludes.is_match(relative) {
88            continue;
89        }
90
91        if let Some((_, pattern)) = feature_globs
92            .iter()
93            .find(|(matcher, _)| matcher.is_match(relative))
94        {
95            files.features.push(FeatureFile {
96                format: FeatureFormat::from_path(relative),
97                path,
98                pattern: pattern.clone(),
99            });
100            continue;
101        }
102
103        if let Some(language) = source_language(relative) {
104            if definition_globs
105                .as_ref()
106                .map(|globs| globs.iter().any(|(matcher, _)| matcher.is_match(relative)))
107                .unwrap_or(true)
108            {
109                files.definitions.push(SourceFile { path, language });
110            }
111        }
112    }
113
114    files
115        .features
116        .sort_by(|left, right| left.path.cmp(&right.path));
117    files.unmatched_feature_patterns = feature_globs
118        .iter()
119        .filter(|(matcher, _)| {
120            !files.features.iter().any(|file| {
121                matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
122            })
123        })
124        .map(|(_, pattern)| pattern.clone())
125        .collect();
126    files
127        .definitions
128        .sort_by(|left, right| left.path.cmp(&right.path));
129    files.unmatched_definition_patterns = definition_globs
130        .as_ref()
131        .into_iter()
132        .flatten()
133        .filter(|(matcher, _)| {
134            !files.definitions.iter().any(|file| {
135                matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
136            })
137        })
138        .map(|(_, pattern)| pattern.clone())
139        .collect();
140    Ok(files)
141}
142
143/// Compiles patterns into matchers paired with their original text for diagnostics.
144fn compile_pattern_matchers(patterns: &[String], kind: &str) -> Result<Vec<(GlobMatcher, String)>> {
145    patterns
146        .iter()
147        .map(|pattern| {
148            let glob = Glob::new(pattern)
149                .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?;
150            Ok((glob.compile_matcher(), pattern.clone()))
151        })
152        .collect()
153}
154
155fn compile_globs(patterns: &[String], kind: &str) -> Result<GlobSet> {
156    let mut builder = GlobSetBuilder::new();
157    for pattern in patterns {
158        builder.add(
159            Glob::new(pattern)
160                .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?,
161        );
162    }
163    builder
164        .build()
165        .with_context(|| format!("failed to build {kind} glob set"))
166}
167
168fn source_language(path: &Path) -> Option<SourceLanguage> {
169    source_adapter::language_for_path(path)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::config::ConfigOverrides;
176    use std::fs;
177
178    #[test]
179    fn discovers_sources_and_features_but_uses_gitignore() {
180        let directory = tempfile::tempdir().unwrap();
181        fs::create_dir_all(directory.path().join("features/steps")).unwrap();
182        fs::create_dir_all(directory.path().join("ignored")).unwrap();
183        fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
184        fs::write(
185            directory.path().join("features/example.feature"),
186            "Feature: x",
187        )
188        .unwrap();
189        fs::write(
190            directory.path().join("features/steps/example.ts"),
191            "Given('x', () => {})",
192        )
193        .unwrap();
194        fs::write(
195            directory.path().join("ignored/step.ts"),
196            "Given('y', () => {})",
197        )
198        .unwrap();
199        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
200
201        let files = discover(&config).unwrap();
202        assert_eq!(files.features.len(), 1);
203        assert_eq!(files.definitions.len(), 1);
204    }
205
206    #[test]
207    fn cuke_dedup_ignore_supports_scoped_gitignore_rules_and_negation() {
208        let directory = tempfile::tempdir().unwrap();
209        fs::create_dir_all(directory.path().join("ignored")).unwrap();
210        fs::create_dir_all(directory.path().join("packages/first")).unwrap();
211        fs::create_dir_all(directory.path().join("packages/second")).unwrap();
212        fs::write(
213            directory.path().join(".cuke-dedupignore"),
214            "ignored/\n*.generated.ts\n!keep.generated.ts\n",
215        )
216        .unwrap();
217        fs::write(
218            directory.path().join("packages/first/.cuke-dedupignore"),
219            "local.ts\n",
220        )
221        .unwrap();
222        for path in [
223            "included.ts",
224            "ignored/step.ts",
225            "drop.generated.ts",
226            "keep.generated.ts",
227            "packages/first/local.ts",
228            "packages/second/local.ts",
229        ] {
230            fs::write(directory.path().join(path), "Given('x', () => work())").unwrap();
231        }
232
233        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
234        let root = config.root.clone();
235        let files = discover(&config).unwrap();
236        let relative = files
237            .definitions
238            .iter()
239            .map(|file| {
240                file.path
241                    .strip_prefix(&root)
242                    .unwrap()
243                    .to_string_lossy()
244                    .replace('\\', "/")
245            })
246            .collect::<Vec<_>>();
247
248        assert_eq!(
249            relative,
250            vec![
251                "included.ts",
252                "keep.generated.ts",
253                "packages/second/local.ts"
254            ]
255        );
256    }
257
258    #[test]
259    fn discovers_classic_and_markdown_features_with_parser_metadata() {
260        let directory = tempfile::tempdir().unwrap();
261        fs::create_dir_all(directory.path().join("features")).unwrap();
262        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
263        fs::write(
264            directory.path().join("features/b.feature.md"),
265            "# Feature: B",
266        )
267        .unwrap();
268        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
269
270        let files = discover(&config).unwrap();
271        assert_eq!(files.features.len(), 2);
272        assert_eq!(files.features[0].format, FeatureFormat::Gherkin);
273        assert_eq!(files.features[1].format, FeatureFormat::GherkinMarkdown);
274        assert!(files.unmatched_feature_patterns.is_empty());
275    }
276
277    #[test]
278    fn overlapping_feature_patterns_are_both_counted_as_matched() {
279        let directory = tempfile::tempdir().unwrap();
280        fs::create_dir_all(directory.path().join("features")).unwrap();
281        fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
282        let config = Config::load(
283            directory.path(),
284            ConfigOverrides {
285                features: Some(vec![
286                    "**/*.feature".to_owned(),
287                    "features/**/*.feature".to_owned(),
288                ]),
289                ..ConfigOverrides::default()
290            },
291        )
292        .unwrap();
293
294        let files = discover(&config).unwrap();
295        assert!(files.unmatched_feature_patterns.is_empty());
296    }
297
298    #[test]
299    fn configured_definition_globs_narrow_source_files() {
300        let directory = tempfile::tempdir().unwrap();
301        fs::create_dir_all(directory.path().join("steps")).unwrap();
302        fs::write(directory.path().join("steps/a.ts"), "").unwrap();
303        fs::write(directory.path().join("other.ts"), "").unwrap();
304        let overrides = ConfigOverrides {
305            definitions: Some(vec!["steps/**/*.ts".to_owned()]),
306            ..ConfigOverrides::default()
307        };
308        let config = Config::load(directory.path(), overrides).unwrap();
309
310        let files = discover(&config).unwrap();
311        assert_eq!(files.definitions.len(), 1);
312        assert!(files.definitions[0].path.ends_with("steps/a.ts"));
313        assert!(files.unmatched_definition_patterns.is_empty());
314    }
315
316    #[test]
317    fn configured_globs_use_documented_cross_separator_and_brace_semantics() {
318        let globs = compile_globs(
319            &[
320                "*.ts".to_owned(),
321                "features/*.{feature,feature.md}".to_owned(),
322            ],
323            "test",
324        )
325        .unwrap();
326        assert!(globs.is_match(Path::new("nested/deep/steps.ts")));
327        assert!(globs.is_match(Path::new("features/nested/example.feature.md")));
328        assert!(!globs.is_match(Path::new("features/example.md")));
329    }
330
331    #[test]
332    fn reports_each_unmatched_definition_glob() {
333        let directory = tempfile::tempdir().unwrap();
334        fs::write(
335            directory.path().join("steps.ts"),
336            "Given('x', () => work())",
337        )
338        .unwrap();
339        let config = Config::load(
340            directory.path(),
341            ConfigOverrides {
342                definitions: Some(vec!["stpes/**/*.ts".to_owned()]),
343                ..ConfigOverrides::default()
344            },
345        )
346        .unwrap();
347
348        let files = discover(&config).unwrap();
349        assert!(files.definitions.is_empty());
350        assert_eq!(files.unmatched_definition_patterns, ["stpes/**/*.ts"]);
351    }
352
353    #[test]
354    fn declaration_and_source_map_files_are_not_definition_sources() {
355        for path in ["types.d.ts", "types.d.mts", "types.d.cts", "code.js.map"] {
356            assert_eq!(source_language(Path::new(path)), None, "{path}");
357        }
358    }
359
360    #[test]
361    fn built_in_excludes_prune_sources_unless_explicitly_disabled() {
362        let directory = tempfile::tempdir().unwrap();
363        fs::create_dir_all(directory.path().join("node_modules/workspace")).unwrap();
364        fs::write(
365            directory.path().join("node_modules/workspace/steps.ts"),
366            "Given('nested', () => work())",
367        )
368        .unwrap();
369
370        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
371        assert!(discover(&default_config).unwrap().definitions.is_empty());
372
373        let enabled_config = Config::load(
374            directory.path(),
375            ConfigOverrides {
376                definitions: Some(vec!["node_modules/workspace/**/*.ts".to_owned()]),
377                exclude_defaults: Some(false),
378                ..ConfigOverrides::default()
379            },
380        )
381        .unwrap();
382        let files = discover(&enabled_config).unwrap();
383        assert_eq!(files.definitions.len(), 1);
384    }
385
386    #[test]
387    fn hidden_sources_require_the_include_hidden_option() {
388        let directory = tempfile::tempdir().unwrap();
389        fs::create_dir_all(directory.path().join(".steps")).unwrap();
390        fs::write(
391            directory.path().join(".steps/example.ts"),
392            "Given('hidden', () => work())",
393        )
394        .unwrap();
395        let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
396        assert!(discover(&default_config).unwrap().definitions.is_empty());
397
398        let visible_config = Config::load(
399            directory.path(),
400            ConfigOverrides {
401                definitions: Some(vec![".steps/**/*.ts".to_owned()]),
402                include_hidden: Some(true),
403                ..ConfigOverrides::default()
404            },
405        )
406        .unwrap();
407        assert_eq!(discover(&visible_config).unwrap().definitions.len(), 1);
408    }
409
410    #[cfg(unix)]
411    #[test]
412    fn traversal_errors_are_collected_without_discarding_other_files() {
413        use std::os::unix::fs::PermissionsExt;
414
415        let directory = tempfile::tempdir().unwrap();
416        fs::write(
417            directory.path().join("visible.ts"),
418            "Given('visible', () => work())",
419        )
420        .unwrap();
421        let locked = directory.path().join("locked");
422        fs::create_dir(&locked).unwrap();
423        fs::write(locked.join("hidden.ts"), "Given('hidden', () => work())").unwrap();
424        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
425
426        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
427        let files = discover(&config).unwrap();
428
429        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
430        assert_eq!(files.definitions.len(), 1);
431        assert!(!files.errors.is_empty());
432    }
433
434    #[cfg(unix)]
435    #[test]
436    fn excluded_directories_are_pruned_before_traversal() {
437        use std::os::unix::fs::PermissionsExt;
438
439        let directory = tempfile::tempdir().unwrap();
440        let locked = directory.path().join("node_modules/locked");
441        fs::create_dir_all(&locked).unwrap();
442        fs::write(locked.join("step.ts"), "Given('hidden', () => work())").unwrap();
443        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
444
445        let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
446        let files = discover(&config).unwrap();
447
448        fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
449        assert!(files.definitions.is_empty());
450        assert!(files.errors.is_empty());
451    }
452}