1use 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#[non_exhaustive]
15pub struct FeatureFile {
16 pub path: PathBuf,
18 pub format: FeatureFormat,
20 pub pattern: String,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25#[non_exhaustive]
27pub struct DiscoveredFiles {
28 pub dependencies: Vec<source_adapter::SourceDependency>,
30 pub dependency_gaps: Vec<source_adapter::DependencyGap>,
32 pub features: Vec<FeatureFile>,
34 pub definitions: Vec<SourceFile>,
36 pub skipped_definitions: Vec<SourceFile>,
40 pub errors: Vec<String>,
42 pub unmatched_feature_patterns: Vec<String>,
44 pub unmatched_definition_patterns: Vec<String>,
46}
47
48pub 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
169fn 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> {
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
196fn names_suffix(matcher: &GlobMatcher, path: &Path) -> bool {
199 !matcher.is_match(path.with_extension("cuke-dedup-suffix-probe"))
200}
201
202fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}