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